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
62 changed files with 4693 additions and 1021 deletions
+30 -28
View File
@@ -11,35 +11,37 @@ POSTGRES_DB=postgres #
# Localization and Display
DEFAULT_LANGUAGE="ru" # or "en"
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
# External Links
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
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
START_COMMAND_DESCRIPTION= # Description of the /start command
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
SUBSCRIPTION_MINI_APP_URL="" # URL of the subscription mini-app
START_COMMAND_DESCRIPTION="" # Description of the /start command
DISABLE_WELCOME_MESSAGE=False # Disable the welcome message
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)
# 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
# Webhook Base URL (used for Telegram and payment providers)
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
TELEGRAM_WEBHOOK_PATH=/webhook/telegram # Relative path for Telegram updates
TELEGRAM_WEBHOOK_SECRET= # Secret token for Telegram webhook validation header
# Payment Method Toggles
YOOKASSA_ENABLED=True # Turn on YOOKASSA
FREEKASSA_ENABLED=True # Turn on FreeKassa
STARS_ENABLED=True # Turn on STARS
TRIBUTE_ENABLED=True # Turn on TRIBUTE
STARS_PROVIDER_TOKEN= # Leave empty for Telegram Stars (XTR)
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
PLATEGA_ENABLED=False # Turn on PLATEGA
SEVERPAY_ENABLED=False # Turn on SeverPay
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, tribute, stars, cryptopay
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,tribute,stars
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
# YooKassa Payment Gateway Configuration
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
@@ -71,44 +73,36 @@ CRYPTOPAY_ASSET=RUB #
# Platega Payment Gateway Configuration
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
PLATEGA_SECRET= # API secret from Platega
PLATEGA_MERCHANT_ID="" # Your MerchantId from Platega
PLATEGA_SECRET="" # API secret from Platega
PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto)
PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link)
PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL)
PLATEGA_RETURN_URL="" # Optional: redirect after successful payment (defaults to bot link)
PLATEGA_FAILED_URL="" # Optional: redirect after failed/cancelled payment (defaults to return URL)
# SeverPay Payment Gateway Configuration
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
SEVERPAY_MID= # Your MID from SeverPay
SEVERPAY_TOKEN= # API token/secret for signing requests
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
SEVERPAY_MID="" # Your MID from SeverPay
SEVERPAY_TOKEN="" # API token/secret for signing requests
SEVERPAY_RETURN_URL="" # Optional: redirect URL after payment (defaults to bot link)
SEVERPAY_LIFETIME_MINUTES="" # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
# Tribute Payment Gateway Configuration
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
# Subscription Options. Specify cost parameters or payment links here.
1_MONTH_ENABLED=True
RUB_PRICE_1_MONTH=150
STARS_PRICE_1_MONTH=0
TRIBUTE_LINK_1_MONTH=
3_MONTHS_ENABLED=True
RUB_PRICE_3_MONTHS=300
STARS_PRICE_3_MONTHS=0
TRIBUTE_LINK_3_MONTHS=
6_MONTHS_ENABLED=True
RUB_PRICE_6_MONTHS=500
STARS_PRICE_6_MONTHS=0
TRIBUTE_LINK_6_MONTHS=
12_MONTHS_ENABLED=True
RUB_PRICE_12_MONTHS=900
STARS_PRICE_12_MONTHS=0
TRIBUTE_LINK_12_MONTHS=
# Traffic Packages (enables traffic sale mode when set)
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
@@ -121,6 +115,7 @@ SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True #
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
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
# Referral Bonus Days
@@ -138,7 +133,7 @@ REFEREE_BONUS_DAYS_12_MONTHS=15
# Panel API Configuration
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
PANEL_API_KEY=your_panel_api_key # Panel API key
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
PANEL_WEBHOOK_SECRET="" # secret used to verify panel webhook signatures
# User traffic limits (applied for all users)
# 0 means unlimited
@@ -148,7 +143,7 @@ USER_TRAFFIC_STRATEGY="NO_RESET" #
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
# Default External Squad for Users (Optional, single UUID)
USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users
USER_EXTERNAL_SQUAD_UUID="" # Optional: UUID from Remnawave External Squads to auto-link new panel users
# Trial Settings
TRIAL_ENABLED=True # Enable the trial period
@@ -158,7 +153,7 @@ TRIAL_TRAFFIC_STRATEGY="NO_RESET" #
# Connection link handling (happ crypt4)
CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs
CRYPT4_REDIRECT_URL= # Base redirect to wrap the connect button, e.g. https://redir.example.com?url=
CRYPT4_REDIRECT_URL="" # Base redirect to wrap the connect button, e.g. https://redir.example.com?url=
# Web Server Settings (for handling webhooks)
WEB_SERVER_HOST="0.0.0.0"
@@ -167,16 +162,23 @@ WEB_SERVER_PORT=8080
# Admin Panel Log Pagination
LOGS_PAGE_SIZE=10 # Number of events in the log
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
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_PAYMENTS=True # Log payments
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
# 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.
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
+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,
});
+2
View File
@@ -17,3 +17,5 @@ __pycache__/
locales/ru_backup.json
locales/en_backup.json
db/models_old.py
.serena/
AGENTS.md
+31 -4
View File
@@ -28,6 +28,7 @@
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
- **aiohttp:** Для запуска веб-сервера (вебхуки).
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
- **Alembic:** Миграции схемы базы данных.
- **YooKassa, FreeKassa API, Platega, SeverPay, aiocryptopay:** Интеграции с платежными системами.
- **Pydantic:** Для управления настройками из `.env` файла.
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
@@ -68,8 +69,10 @@
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | Включить/выключить обязательную проверку подписки на канал (`true`/`false`). | `false` |
| `REQUIRED_CHANNEL_ID` | ID канала для проверки подписки. Используется, только если `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE=true`. | `-1001234567890` |
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
| `REFERRAL_ENABLED` | Включить/выключить реферальную систему полностью (`true`/`false`). | `true` |
</details>
<details>
@@ -78,7 +81,9 @@
| Переменная | Описание |
| --- | --- |
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
| `TELEGRAM_WEBHOOK_PATH` | Относительный путь Telegram вебхука. По умолчанию `/webhook/telegram`. |
| `TELEGRAM_WEBHOOK_SECRET` | (Рекомендуется) Секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token`. |
| `WEB_SERVER_HOST` | Хост для веб-сервера. По умолчанию `0.0.0.0`. | `0.0.0.0` |
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. |
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
@@ -98,6 +103,7 @@
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
| `STARS_PROVIDER_TOKEN` | Токен провайдера Telegram invoice. Для Stars (XTR) оставить пустым. |
| `PLATEGA_ENABLED`| Включить/выключить Platega (`true`/`false`). |
| `PLATEGA_MERCHANT_ID`| MerchantId из личного кабинета Platega. |
| `PLATEGA_SECRET`| API секрет для запросов Platega. |
@@ -112,6 +118,18 @@
| `SEVERPAY_LIFETIME_MINUTES` | (Опционально) Время жизни платежной ссылки в минутах (30–4320). |
</details>
<details>
<summary><b>Настройки логирования</b></summary>
| Переменная | Описание | Пример |
| --- | --- | --- |
| `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>
<details>
<summary><b>Настройки подписок</b></summary>
@@ -166,7 +184,7 @@
- `https://<ваш_домен>/webhook/severpay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/severpay`
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
- `https://<ваш_домен>/webhook/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-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
@@ -175,7 +193,16 @@
docker compose logs -f remnawave-tg-shop
```
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
> 💡 Если включена проверка подписки (`REQUIRED_CHANNEL_SUBSCRIBE_TO_USE=true`), добавьте бота администратором в канал из `REQUIRED_CHANNEL_ID`. Пользователь увидит кнопку «Проверить подписку», и после успешного подтверждения доступ продолжится.
### Миграции БД (Alembic)
- При запуске `python main.py` миграции применяются автоматически до `head`.
- Для ручного запуска используйте:
```bash
alembic upgrade head
```
## Подробная инструкция для развертывания на сервере с панелью Remnawave
+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 sqlalchemy.orm import sessionmaker
@@ -81,12 +82,14 @@ def build_core_services(
# Wire services that depend on each other
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
setattr(subscription_service, "yookassa_service", yookassa_service)
# Allow panel webhook to trigger renewals through subscription service
setattr(panel_webhook_service, "subscription_service", subscription_service)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/app/factories/build_services.py: %s", exc)
return {
"panel_service": panel_service,
+14 -4
View File
@@ -41,12 +41,22 @@ async def build_and_start_web_app(
setup_application(app, dp, bot=bot)
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:
telegram_webhook_path = f"/{settings.BOT_TOKEN}"
app.router.add_post(telegram_webhook_path, SimpleRequestHandler(dispatcher=dp, bot=bot))
telegram_webhook_path = settings.telegram_webhook_path
app.router.add_post(
telegram_webhook_path,
SimpleRequestHandler(
dispatcher=dp,
bot=bot,
secret_token=telegram_webhook_secret,
),
)
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
@@ -59,7 +69,7 @@ async def build_and_start_web_app(
cp_path = settings.cryptopay_webhook_path
if cp_path.startswith("/"):
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
if fk_path.startswith("/"):
+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)
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/admin/ads.py: %s", exc)
@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"))
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/admin/ads.py: %s", exc)
@router.message(
+2 -2
View File
@@ -171,8 +171,8 @@ async def change_broadcast_target_handler(
current_lang, i18n, target=new_target
),
)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/admin/broadcast.py: %s", exc)
await callback.answer()
+36 -17
View File
@@ -171,9 +171,17 @@ async def view_all_logs_handler(callback: types.CallbackQuery,
await callback.answer("Error processing request.", show_alert=True)
return
hide_admin_events = bool(settings.LOG_ADMIN_HIDE)
logs_models = await message_log_dal.get_all_message_logs(
session, settings.LOGS_PAGE_SIZE, page_idx * settings.LOGS_PAGE_SIZE)
total_logs_count = await message_log_dal.count_all_message_logs(session)
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(
target_message=callback.message,
@@ -344,8 +352,12 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
try:
# Get all logs (limit to 10000 for performance)
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:
await callback.message.answer(_(
"admin_logs_csv_no_data"
@@ -355,7 +367,7 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
# Create CSV content
csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)
# Write header
headers = [
_("admin_csv_header_log_id"),
@@ -364,22 +376,26 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
_("admin_csv_header_telegram_username"),
_("admin_csv_header_telegram_first_name"),
_("admin_csv_header_event_type"),
_("admin_csv_header_content"),
_("admin_csv_header_is_admin_event"),
_("admin_csv_header_target_user_id"),
_("admin_csv_header_raw_update_preview")
]
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)
# Write data rows
for log in logs_models:
# Format timestamp
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)
content_clean = (log.content or '').replace('\n', ' ').replace('\r', ' ').strip()
raw_update_clean = (log.raw_update_preview or '').replace('\n', ' ').replace('\r', ' ').strip()
row = [
log.log_id or '',
timestamp_str,
@@ -387,27 +403,30 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
log.telegram_username or '',
log.telegram_first_name or '',
log.event_type or '',
content_clean,
'Yes' if log.is_admin_event else 'No',
log.target_user_id or '',
raw_update_clean
]
if include_sensitive_fields:
row.extend([
content_clean,
raw_update_clean,
])
csv_writer.writerow(row)
# Create file
csv_content = csv_buffer.getvalue()
csv_buffer.close()
# Generate filename with current timestamp
now = datetime.now()
filename = f"message_logs_{now.strftime('%Y%m%d_%H%M%S')}.csv"
# Send as document
csv_file = types.BufferedInputFile(
csv_content.encode('utf-8-sig'), # BOM for Excel compatibility
filename=filename
)
await callback.message.answer_document(
csv_file,
caption=_(
@@ -416,7 +435,7 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
date=now.strftime('%Y-%m-%d %H:%M:%S')
)
)
except Exception as e:
logging.error(f"Error exporting logs to CSV: {e}", exc_info=True)
await callback.message.answer(_(
+2 -2
View File
@@ -1,5 +1,5 @@
import logging
import random
import secrets
import string
import csv
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:
"""Generate a unique random promo code"""
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
+196 -45
View File
@@ -28,25 +28,82 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery,
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
# Step 1: Ask for promo code
# Step 0: Ask for promo type (bonus_days or discount)
prompt_text = _(
"admin_promo_step1_code"
"admin_promo_step0_type"
)
# Create keyboard for type selection
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=_("admin_promo_type_bonus_days"),
callback_data="promo_type_select:bonus_days"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_type_discount"),
callback_data="promo_type_select:discount"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_back_to_panel"),
callback_data="admin_action:main"
)
)
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
reply_markup=builder.as_markup(),
parse_mode="HTML")
except Exception as e:
logging.warning(
f"Could not edit message for promo prompt: {e}. Sending new.")
f"Could not edit message for promo type prompt: {e}. Sending new.")
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
reply_markup=builder.as_markup(),
parse_mode="HTML")
await callback.answer()
await state.set_state(AdminStates.waiting_for_promo_code)
await state.set_state(AdminStates.waiting_for_promo_type_selection)
# Step 0: Process type selection
@router.callback_query(F.data.startswith("promo_type_select:"), StateFilter(AdminStates.waiting_for_promo_type_selection))
async def process_promo_type_selection(callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error processing type selection.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
promo_type = callback.data.split(":")[-1] # "bonus_days" or "discount"
await state.update_data(promo_type=promo_type)
# Step 1: Ask for promo code
prompt_text = _(
"admin_promo_step1_code"
)
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await callback.answer()
await state.set_state(AdminStates.waiting_for_promo_code)
except Exception as e:
logging.error(f"Error processing promo type selection: {e}")
await callback.message.answer(_("error_occurred_try_again"))
await callback.answer()
# Step 1: Process promo code
@@ -80,19 +137,31 @@ async def process_promo_code_handler(message: types.Message,
return
await state.update_data(promo_code=code_str)
# Step 2: Ask for bonus days
prompt_text = _(
"admin_promo_step2_bonus_days",
code=code_str
)
# Get promo type from state
data = await state.get_data()
promo_type = data.get("promo_type", "bonus_days")
# Step 2: Ask for bonus days OR discount percentage based on type
if promo_type == "discount":
prompt_text = _(
"admin_promo_step2_discount_percentage",
code=code_str
)
next_state = AdminStates.waiting_for_promo_discount_percentage
else:
prompt_text = _(
"admin_promo_step2_bonus_days",
code=code_str
)
next_state = AdminStates.waiting_for_promo_bonus_days
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_promo_bonus_days)
await state.set_state(next_state)
except Exception as e:
logging.error(f"Error processing promo code: {e}")
@@ -119,9 +188,9 @@ async def process_promo_bonus_days_handler(message: types.Message,
"admin_promo_invalid_bonus_days"
))
return
await state.update_data(bonus_days=bonus_days)
# Step 3: Ask for max activations
data = await state.get_data()
prompt_text = _(
@@ -129,14 +198,14 @@ async def process_promo_bonus_days_handler(message: types.Message,
code=data.get("promo_code"),
bonus_days=bonus_days
)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_promo_max_activations)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number"
@@ -146,6 +215,53 @@ async def process_promo_bonus_days_handler(message: types.Message,
await message.answer(_("error_occurred_try_again"))
# Step 2: Process discount percentage
@router.message(AdminStates.waiting_for_promo_discount_percentage, F.text)
async def process_promo_discount_percentage_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
discount_percentage = int(message.text.strip())
if not (1 <= discount_percentage <= 100):
await message.answer(
"❌ Discount percentage must be between 1 and 100."
)
return
await state.update_data(discount_percentage=discount_percentage)
# Step 3: Ask for max activations
data = await state.get_data()
prompt_text = _(
"admin_promo_step3_max_activations_discount",
code=data.get("promo_code"),
discount_percentage=discount_percentage
)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_promo_max_activations)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number"
))
except Exception as e:
logging.error(f"Error processing discount percentage: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 3: Process max activations
@router.message(AdminStates.waiting_for_promo_max_activations, F.text)
async def process_promo_max_activations_handler(message: types.Message,
@@ -168,15 +284,25 @@ async def process_promo_max_activations_handler(message: types.Message,
return
await state.update_data(max_activations=max_activations)
# Step 4: Ask for validity
data = await state.get_data()
prompt_text = _(
"admin_promo_step4_validity",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=max_activations
)
promo_type = data.get("promo_type", "bonus_days")
if promo_type == "discount":
prompt_text = _(
"admin_promo_step4_validity_discount",
code=data.get("promo_code"),
discount_percentage=data.get("discount_percentage"),
max_activations=max_activations
)
else:
prompt_text = _(
"admin_promo_step4_validity",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=max_activations
)
# Create keyboard for validity options
builder = InlineKeyboardBuilder()
@@ -240,12 +366,15 @@ async def process_promo_set_validity(callback: types.CallbackQuery,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
data = await state.get_data()
prompt_text = _(
"admin_promo_enter_validity_days",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=data.get("max_activations")
)
promo_type = data.get("promo_type", "bonus_days")
# Display the correct text based on promo type
if promo_type == "discount":
value_info = f"{data.get('discount_percentage')}%"
else:
value_info = f"{data.get('bonus_days')} дней"
prompt_text = f"⏰ Введите количество дней действия промокода (1-365):\n\nКод: <b>{data.get('promo_code')}</b>\n{'Скидка' if promo_type == 'discount' else 'Бонус'}: <b>{value_info}</b>\nМакс. активаций: <b>{data.get('max_activations')}</b>"
try:
await callback.message.edit_text(
@@ -310,40 +439,60 @@ async def create_promo_code_final(callback_or_message,
try:
data = await state.get_data()
promo_type = data.get("promo_type", "bonus_days")
# Prepare promo code data
promo_data = {
"code": data["promo_code"],
"bonus_days": data["bonus_days"],
"promo_type": promo_type,
"max_activations": data["max_activations"],
"current_activations": 0,
"is_active": True,
"created_by_admin_id": callback_or_message.from_user.id,
"created_at": datetime.now(timezone.utc)
}
# Set type-specific fields
if promo_type == "discount":
promo_data["discount_percentage"] = data["discount_percentage"]
promo_data["bonus_days"] = None
else:
promo_data["bonus_days"] = data["bonus_days"]
promo_data["discount_percentage"] = None
# Set validity
if data.get("validity_days"):
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"])
else:
promo_data["valid_until"] = None
# Create promo code
created_promo = await promo_code_dal.create_promo_code(session, promo_data)
await session.commit()
# Log successful creation
logging.info(f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}")
logging.info(f"Promo code '{data['promo_code']}' ({promo_type}) created with ID {created_promo.promo_code_id}")
# Success message
valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} дней"
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
)
# Format success message based on type
if promo_type == "discount":
success_text = _(
"admin_promo_created_success_discount",
code=data["promo_code"],
discount_percentage=data['discount_percentage'],
max_activations=data["max_activations"],
valid_until_str=valid_until_str
)
else:
success_text = _(
"admin_promo_created_success",
code=data["promo_code"],
bonus_days=data['bonus_days'],
max_activations=data["max_activations"],
valid_until_str=valid_until_str
)
if hasattr(callback_or_message, 'message'): # CallbackQuery
try:
@@ -385,8 +534,10 @@ async def create_promo_code_final(callback_or_message,
@router.callback_query(
F.data == "admin_action:main",
StateFilter(
AdminStates.waiting_for_promo_type_selection,
AdminStates.waiting_for_promo_code,
AdminStates.waiting_for_promo_bonus_days,
AdminStates.waiting_for_promo_discount_percentage,
AdminStates.waiting_for_promo_max_activations,
AdminStates.waiting_for_promo_validity_days,
),
+70 -17
View File
@@ -47,9 +47,19 @@ async def get_promo_detail_text_and_keyboard(promo_id: int, session: AsyncSessio
created = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A"
# Determine promo type and value to display
promo_type = getattr(promo, "promo_type", "bonus_days")
if promo_type == "discount":
type_name = _("admin_promo_type_discount")
value_line = _("admin_promo_card_discount_percentage", percentage=promo.discount_percentage)
else:
type_name = _("admin_promo_type_bonus_days")
value_line = _("admin_promo_card_bonus_days", days=promo.bonus_days)
text = "\n".join([
_("admin_promo_card_title", code=promo.code),
_("admin_promo_card_bonus_days", days=promo.bonus_days),
_("admin_promo_card_type", type=type_name),
value_line,
_("admin_promo_card_activations", current=promo.current_activations, max=promo.max_activations),
_("admin_promo_card_validity", validity=validity),
_("admin_promo_card_status", status=status),
@@ -76,12 +86,22 @@ async def view_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dic
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_models = await promo_code_dal.get_all_active_promo_codes(session, limit=20, offset=0)
text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}" if not promo_models else "\n".join(
[_("admin_active_promos_list_header"), ""] + [
f"{get_promo_status_emoji_and_text(p, i18n, current_lang)[0]} <code>{p.code}</code> | 🎁 {p.bonus_days}д | 📊 {p.current_activations}/{p.max_activations} | ⏰ {p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')}"
for p in promo_models
]
)
if not promo_models:
text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}"
else:
promo_lines = [_("admin_active_promos_list_header"), ""]
for p in promo_models:
status_emoji = get_promo_status_emoji_and_text(p, i18n, current_lang)[0]
promo_type = getattr(p, "promo_type", "bonus_days")
if promo_type == "discount":
value_display = f"💰 {p.discount_percentage}%"
else:
value_display = f"🎁 {p.bonus_days}д"
validity_display = p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')
promo_lines.append(
f"{status_emoji} <code>{p.code}</code> | {value_display} | 📊 {p.current_activations}/{p.max_activations} | ⏰ {validity_display}"
)
text = "\n".join(promo_lines)
await callback.message.edit_text(text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML")
await callback.answer()
@@ -300,7 +320,9 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
# CSV headers (forced to English)
writer.writerow([
i18n.gettext(export_lang, "admin_promo_csv_code"),
"Type",
i18n.gettext(export_lang, "admin_promo_csv_bonus_days"),
"Discount %",
i18n.gettext(export_lang, "admin_promo_csv_max_activations"),
i18n.gettext(export_lang, "admin_promo_csv_current_activations"),
i18n.gettext(export_lang, "admin_promo_csv_status"),
@@ -309,15 +331,22 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
i18n.gettext(export_lang, "admin_promo_csv_created_at"),
i18n.gettext(export_lang, "admin_promo_csv_created_by_admin_id"),
])
for promo in all_promos:
# Определяем статус
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, export_lang)
# Determine promo type and values
promo_type = getattr(promo, "promo_type", "bonus_days")
bonus_days_val = promo.bonus_days if promo_type == "bonus_days" else "N/A"
discount_val = promo.discount_percentage if promo_type == "discount" else "N/A"
# Формируем данные для CSV
row = [
promo.code,
promo.bonus_days,
promo_type,
bonus_days_val,
discount_val,
promo.max_activations,
promo.current_activations,
status_text,
@@ -374,13 +403,26 @@ async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: di
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_id = int(callback.data.split(":")[1])
# Get promo to check type
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
if not promo:
await callback.answer(_("admin_promo_not_found"), show_alert=True)
return
promo_type = getattr(promo, "promo_type", "bonus_days")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_bonus_days"), callback_data=f"promo_edit_field:bonus_days:{promo_id}"))
# Show appropriate edit option based on type
if promo_type == "discount":
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_discount_percentage"), callback_data=f"promo_edit_field:discount_percentage:{promo_id}"))
else:
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_bonus_days"), callback_data=f"promo_edit_field:bonus_days:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_max_activations"), callback_data=f"promo_edit_field:max_activations:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_validity"), callback_data=f"promo_edit_field:valid_until:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}"))
await callback.message.edit_text(_("admin_promo_edit_select_field"), reply_markup=builder.as_markup())
await callback.answer()
@@ -394,14 +436,19 @@ async def promo_edit_field_handler(callback: types.CallbackQuery, state: FSMCont
action, field, promo_id_str = callback.data.split(":")
await state.update_data(promo_id=int(promo_id_str), field_to_edit=field)
prompts = {
"bonus_days": "admin_promo_prompt_bonus_days",
"discount_percentage": "admin_promo_prompt_discount_percentage",
"max_activations": "admin_promo_prompt_max_activations",
"valid_until": "admin_promo_prompt_validity_days"
}
prompt_key = prompts.get(field, "error_occurred_try_again")
prompt_text = _(prompt_key)
await state.set_state(AdminStates.waiting_for_promo_edit_details)
await callback.message.edit_text(_(prompts.get(field, "error_occurred_try_again")))
await callback.message.edit_text(prompt_text)
await callback.answer()
@router.message(StateFilter(AdminStates.waiting_for_promo_edit_details))
@@ -418,9 +465,15 @@ async def process_promo_edit_details(message: types.Message, state: FSMContext,
try:
value = message.text
update_data = {}
if field == "bonus_days":
update_data["bonus_days"] = int(value)
elif field == "discount_percentage":
discount_pct = int(value)
if not (1 <= discount_pct <= 100):
await message.answer("❌ Discount percentage must be between 1 and 100.")
return
update_data["discount_percentage"] = discount_pct
elif field == "max_activations":
update_data["max_activations"] = int(value)
elif field == "valid_until":
@@ -433,7 +486,7 @@ async def process_promo_edit_details(message: types.Message, state: FSMContext,
if await promo_code_dal.update_promo_code(session, promo_id, update_data):
await session.commit()
await message.answer(_("admin_promo_edit_success"))
# Reset state and show updated details
await state.clear()
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
+419 -181
View File
@@ -1,6 +1,5 @@
import logging
import json
import asyncio
from datetime import datetime, timezone, timedelta
from typing import Optional, Dict, Any
@@ -26,8 +25,6 @@ from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from bot.utils.config_link import prepare_config_links
payment_processing_lock = asyncio.Lock()
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture'
@@ -39,7 +36,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
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", {})
user_id_str = metadata.get("user_id")
subscription_months_str = metadata.get("subscription_months")
@@ -50,80 +48,135 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
auto_renew_subscription_id_str = metadata.get(
"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 (
not user_id_str
or (not subscription_months_str and not traffic_gb_str)
or (not payment_db_id_str and not auto_renew_subscription_id_str)
or not payment_db_id_str
):
logging.error(
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
)
return
return False
db_user = None
payment_before_update = None
try:
user_id = int(user_id_str)
subscription_months = float(subscription_months_str or 0)
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
payment_db_id = int(
payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id and sale_mode != "traffic")
if not payment_db_id_str.isdigit():
logging.error(
"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_str
) if promo_code_id_str and promo_code_id_str.isdigit() else None
amount_data = payment_info_from_webhook.get("amount", {})
months_for_record = int(subscription_months) if sale_mode != "traffic" else 0
payment_value = float(amount_data.get("value", 0.0))
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
payment_record = None
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
if payment_db_id is None and auto_renew_subscription_id_str:
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 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:
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(
"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
from db.dal import payment_dal as _payment_dal
payment_record = await _payment_dal.get_payment_by_provider_payment_id(
session, yk_payment_id_from_hook
)
if not payment_record:
payment_record = await _payment_dal.ensure_payment_with_provider_id(
session,
user_id=user_id,
amount=payment_value,
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
months=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,
return False
if payment_record and round(float(payment_record.amount), 2) != round(provider_amount, 2):
logging.error(
"YooKassa webhook verification failed: DB amount mismatch for payment %s (db %.2f vs provider %.2f)",
payment_record.payment_id,
float(payment_record.amount),
provider_amount,
)
payment_db_id = payment_record.payment_id
except Exception as e_ensure:
return False
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(
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}",
exc_info=True,
"YooKassa webhook verification failed for payment %s: cannot validate amount (%s)",
yk_payment_id_from_hook,
e_amount_verify,
)
return
elif payment_db_id is not None:
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment_record:
logging.error(
f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}."
)
return
return False
if payment_record and payment_record.status == "succeeded":
logging.info(
f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})."
)
return
return True
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
@@ -135,7 +188,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
session, payment_db_id, "failed_user_not_found",
payment_info_from_webhook.get("id"))
return
return False
except (TypeError, ValueError) as e:
logging.error(
@@ -151,16 +204,62 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
logging.error(
f"Failed to update payment status after metadata error: {e_upd}"
)
return
return False
try:
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:
payment_before_update = await payment_dal.get_payment_by_db_id(
session,
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(
lknpd_service
and lknpd_service.configured
@@ -192,7 +291,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
else:
display_last4 = None
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_last4 = None
@@ -217,37 +316,58 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
logging.exception("Failed to persist multi-card YooKassa method from webhook")
except Exception:
logging.exception("Failed to persist YooKassa payment method from webhook")
months_for_activation = int(subscription_months) if sale_mode != "traffic" else 0
activation_details = await subscription_service.activate_subscription(
session,
user_id,
months_for_activation,
payment_value,
payment_db_id,
promo_code_id_from_payment=promo_code_id,
provider="yookassa",
sale_mode=sale_mode,
traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None,
)
try:
activation_details = await subscription_service.activate_subscription(
session,
user_id,
months_for_activation,
payment_value,
payment_db_id,
promo_code_id_from_payment=promo_code_id,
provider="yookassa",
sale_mode=sale_mode,
traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None,
)
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'):
logging.error(
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}"
)
raise Exception(
f"Subscription Error: Failed to activate for user {user_id}")
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=payment_db_id,
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}"
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,
)
raise Exception(
f"DB Error: Could not update payment record {payment_db_id}")
return False
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']
final_end_date_for_user = base_subscription_end_date
@@ -313,7 +433,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
details_message = _(
"payment_successful_traffic_full",
traffic_gb=traffic_label,
end_date=final_end_date_for_user.strftime('%Y-%m-%d') if final_end_date_for_user else "",
end_date=final_end_date_for_user.strftime('%Y-%m-%d') if final_end_date_for_user else "-",
config_link=config_link_text,
)
details_markup = get_connect_and_main_keyboard(
@@ -395,7 +515,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
await notification_service.notify_payment_received(
user_id=user_id,
amount=payment_value,
currency=settings.DEFAULT_CURRENCY_SYMBOL,
currency="RUB",
months=int(subscription_months) if sale_mode != "traffic" else 0,
payment_provider="yookassa", # This is specifically for YooKassa webhook
username=user.username if user else None,
@@ -404,6 +524,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
except Exception as e:
logging.error(f"Failed to send payment notification: {e}")
return True
except Exception as e_process:
logging.error(
f"Error during process_successful_payment main try block for user {user_id}: {e_process}",
@@ -470,6 +592,7 @@ async def yookassa_webhook_route(request: web.Request):
i18n_instance: JsonI18n = request.app['i18n']
settings: Settings = request.app['settings']
panel_service: PanelApiService = request.app['panel_service']
yookassa_service: Optional[YooKassaService] = request.app.get('yookassa_service')
subscription_service: SubscriptionService = request.app[
'subscription_service']
referral_service: ReferralService = request.app['referral_service']
@@ -501,7 +624,7 @@ async def yookassa_webhook_route(request: web.Request):
logging.error(
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)
pm_obj = getattr(payment_data_from_notification, 'payment_method', None)
@@ -556,116 +679,231 @@ async def yookassa_webhook_route(request: web.Request):
"payment_method": pm_dict,
}
async with payment_processing_lock:
async with async_session_factory() as session:
try:
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
if payment_dict_for_processing.get(
"paid") and payment_dict_for_processing.get(
"status") == "succeeded":
await process_successful_payment(
session, bot, payment_dict_for_processing,
i18n_instance, settings, panel_service,
subscription_service, referral_service,
lknpd_service)
await session.commit()
else:
logging.warning(
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} "
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', "
f"paid='{payment_dict_for_processing.get('paid')}'"
)
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
await process_cancelled_payment(
async with async_session_factory() as session:
try:
# Defense-in-depth: for state mutations beyond succeeded, verify against provider API.
if notification_object.event in {
YOOKASSA_EVENT_PAYMENT_CANCELED,
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE,
}:
if not yookassa_service or not yookassa_service.configured:
logging.critical(
"YooKassa webhook rejected: verification service is not configured for event %s (payment_id=%s)",
notification_object.event,
payment_dict_for_processing.get("id"),
)
return web.Response(status=503, text="yookassa_verification_required")
provider_payment_info = await yookassa_service.get_payment_info(
payment_dict_for_processing.get("id")
)
if not provider_payment_info:
logging.error(
"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,
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()
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
# Bind-only flow: save method and cancel auth if metadata has bind_only
metadata = payment_dict_for_processing.get("metadata", {}) or {}
if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1":
try:
user_id_str = metadata.get("user_id")
if user_id_str and user_id_str.isdigit():
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")
card = payment_method.get("card") or {}
account_number = payment_method.get("account_number") or payment_method.get("account")
display_network = None
display_last4 = None
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
display_network = card.get("card_type") or title or "Card"
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
# Normalize wallet display name to avoid leaking full account from title
display_network = "YooMoney"
if isinstance(account_number, str) and len(account_number) >= 4:
display_last4 = account_number[-4:]
else:
display_last4 = None
else:
display_network = title or (pm_type.upper() if pm_type else "Payment method")
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
else:
logging.warning(
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} "
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', "
f"paid='{payment_dict_for_processing.get('paid')}'"
)
await session.rollback()
return web.Response(status=503, text="yookassa_invalid_succeeded_payload")
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
if payment_dict_for_processing.get("status") not in {"canceled", "cancelled"}:
logging.error(
"YooKassa webhook rejected: canceled event status mismatch for payment %s (status=%s)",
payment_dict_for_processing.get("id"),
payment_dict_for_processing.get("status"),
)
return web.Response(status=503, text="yookassa_invalid_canceled_payload")
await process_cancelled_payment(
session, bot, payment_dict_for_processing,
i18n_instance, settings)
await session.commit()
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
# Bind-only flow: save method and cancel auth if metadata has bind_only
metadata = payment_dict_for_processing.get("metadata", {}) or {}
if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1":
if payment_dict_for_processing.get("status") != "waiting_for_capture":
logging.error(
"YooKassa webhook rejected: waiting_for_capture event status mismatch for payment %s (status=%s)",
payment_dict_for_processing.get("id"),
payment_dict_for_processing.get("status"),
)
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,
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_network=display_network,
set_default=True,
)
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,
user_id=user_id,
provider_payment_method_id=payment_method.get("id"),
provider="yookassa",
card_last4=display_last4,
card_network=display_network,
set_default=True,
)
await session.commit()
except Exception:
await session.rollback()
# Notify user about successful binding with Back button
try:
# Use user's DB language for bind success notification
i18n_lang = settings.DEFAULT_LANGUAGE
from db.dal import user_dal
db_user = await user_dal.get_user_by_id(session, user_id)
if db_user and db_user.language_code:
i18n_lang = db_user.language_code
_ = lambda key, **kwargs: i18n_instance.gettext(i18n_lang, key, **kwargs)
from bot.keyboards.inline.user_keyboards import get_back_to_payment_methods_keyboard
await bot.send_message(
chat_id=user_id,
text=_("payment_method_bound_success"),
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
)
except Exception:
pass
# Attempt to cancel the authorization to avoid charge hold
try:
yk: YooKassaService = request.app.get('yookassa_service')
if yk:
await yk.cancel_payment(payment_dict_for_processing.get("id"))
except Exception:
logging.exception("Failed to cancel bind-only payment auth")
except Exception:
logging.exception("Failed to handle bind-only waiting_for_capture webhook")
except Exception as e_webhook_db_processing:
await session.rollback()
logging.error(
f"Error processing YooKassa webhook event '{notification_object.event}' "
f"for YK Payment ID {payment_dict_for_processing.get('id')} in DB transaction: {e_webhook_db_processing}",
exc_info=True)
return web.Response(
status=200, text="ok_internal_processing_error_logged")
except Exception:
await session.rollback()
# Notify user about successful binding with Back button
try:
# Use user's DB language for bind success notification
i18n_lang = settings.DEFAULT_LANGUAGE
from db.dal import user_dal
db_user = await user_dal.get_user_by_id(session, user_id)
if db_user and db_user.language_code:
i18n_lang = db_user.language_code
_ = lambda key, **kwargs: i18n_instance.gettext(i18n_lang, key, **kwargs)
from bot.keyboards.inline.user_keyboards import get_back_to_payment_methods_keyboard
await bot.send_message(
chat_id=user_id,
text=_("payment_method_bound_success"),
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
)
except Exception as exc:
logging.debug(
"Failed to notify user %s about payment method binding: %s",
user_id,
exc,
)
# Attempt to cancel the authorization to avoid charge hold
try:
yk: YooKassaService = request.app.get('yookassa_service')
if yk:
await yk.cancel_payment(payment_dict_for_processing.get("id"))
except Exception:
logging.exception("Failed to cancel bind-only payment auth")
except Exception:
logging.exception("Failed to handle bind-only waiting_for_capture webhook")
except Exception as e_webhook_db_processing:
await session.rollback()
logging.error(
f"Error processing YooKassa webhook event '{notification_object.event}' "
f"for YK Payment ID {payment_dict_for_processing.get('id')} in DB transaction: {e_webhook_db_processing}",
exc_info=True)
return web.Response(
status=503, text="yookassa_processing_error_retry")
return web.Response(status=200, text="ok")
@@ -676,5 +914,5 @@ async def yookassa_webhook_route(request: web.Request):
logging.error(
f"YooKassa Webhook general processing error: {e_general_webhook}",
exc_info=True)
return web.Response(status=200,
text="ok_general_internal_error_logged")
return web.Response(status=503,
text="yookassa_general_error_retry")
+56 -9
View File
@@ -123,13 +123,15 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
code=hcode(code_input.upper()))
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
else:
# Try as BONUS code first (existing behavior)
success, result = await promo_code_service.apply_promo_code(
session, user.id, code_input, current_lang)
if success:
# Bonus code success
await session.commit()
logging.info(
f"Promo code '{code_input}' successfully applied for user {user.id}."
f"Bonus promo code '{code_input}' successfully applied for user {user.id}."
)
new_end_date = result if isinstance(result, datetime) else None
@@ -151,15 +153,60 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
connect_button_url=connect_button_url,
)
else:
await session.rollback()
logging.info(
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
)
response_to_user_text = result
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
# Bonus code failed, try as DISCOUNT code
success_discount, result_discount = await promo_code_service.apply_discount_promo_code(
session, user.id, code_input, current_lang
)
if success_discount:
# Discount code success
await session.commit()
logging.info(
f"Discount promo code '{code_input}' successfully applied for user {user.id}."
)
discount_pct = result_discount # Returns percentage
# Send notification about discount promo activation
if settings.LOG_PROMO_ACTIVATIONS:
try:
from bot.services.notification_service import NotificationService
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_discount_promo_activation(
user_id=user.id,
promo_code=code_input.upper(),
discount_percentage=discount_pct,
username=user.username
)
except Exception as e:
logging.error(f"Failed to send discount promo activation notification: {e}")
response_to_user_text = _(
"discount_promo_code_applied_success",
code=hcode(code_input.upper()),
discount=discount_pct
)
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
else:
# Both failed
await session.rollback()
logging.info(
f"Promo code '{code_input}' application failed for user {user.id}. "
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(
response_to_user_text,
reply_markup=reply_markup,
+12
View File
@@ -43,6 +43,15 @@ async def referral_command_handler(event: Union[types.Message,
_ = 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:
bot_info = await bot.get_me()
bot_username = bot_info.username
@@ -136,6 +145,9 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if action == "share_message":
if not settings.REFERRAL_ENABLED:
await callback.answer(_("referral_no_bonuses_configured"), show_alert=True)
return
try:
bot_info = await bot.get_me()
bot_username = bot_info.username
+110 -40
View File
@@ -48,13 +48,13 @@ async def send_main_menu(target_event: Union[types.Message,
if isinstance(target_event, types.CallbackQuery):
try:
await target_event.answer(err_msg_fallback, show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
elif isinstance(target_event, types.Message):
try:
await target_event.answer(err_msg_fallback)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
return
@@ -102,8 +102,8 @@ async def send_main_menu(target_event: Union[types.Message,
if isinstance(target_event, types.CallbackQuery):
try:
await target_event.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
except Exception as e_send_edit:
logging.warning(
f"Failed to send/edit main menu (user: {user_id}, is_edit: {is_edit}): {type(e_send_edit).__name__} - {e_send_edit}."
@@ -119,8 +119,8 @@ async def send_main_menu(target_event: Union[types.Message,
try:
await target_event.answer(
_("error_occurred_try_again") if is_edit else None)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
async def ensure_required_channel_subscription(
@@ -134,8 +134,15 @@ async def ensure_required_channel_subscription(
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.
"""
if not settings.REQUIRED_CHANNEL_SUBSCRIBE_TO_USE:
return True
required_channel_id = settings.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
if isinstance(event, types.CallbackQuery):
@@ -213,13 +220,13 @@ async def ensure_required_channel_subscription(
if isinstance(event, types.CallbackQuery):
try:
await event.answer(error_text, show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
if message_obj:
try:
await message_obj.answer(error_text)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
else:
await event.answer(error_text)
return False
@@ -234,13 +241,13 @@ async def ensure_required_channel_subscription(
if isinstance(event, types.CallbackQuery):
try:
await event.answer(error_text, show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
if message_obj:
try:
await message_obj.answer(error_text)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
else:
await event.answer(error_text)
return False
@@ -289,12 +296,12 @@ async def ensure_required_channel_subscription(
if keyboard is None and message_obj:
try:
await message_obj.answer(prompt_text)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
try:
await event.answer(prompt_text, show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
else:
await event.answer(prompt_text, reply_markup=keyboard)
@@ -327,7 +334,7 @@ async def start_command_handler(message: types.Message,
promo_code_to_apply: 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)
if raw_ref_value.isdigit():
if settings.LEGACY_REFS:
@@ -345,6 +352,11 @@ async def start_command_handler(message: types.Message,
session, normalized_code)
if ref_user and ref_user.user_id != 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:
promo_code_to_apply = promo_match.group(1)
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
@@ -450,8 +462,8 @@ async def start_command_handler(message: types.Message,
logging.error(f"Failed to attribute user {user_id} to ad '{ad_start_param}': {e_attr}")
try:
await session.rollback()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
if not await ensure_required_channel_subscription(message, settings, i18n,
current_lang, session,
@@ -465,14 +477,13 @@ async def start_command_handler(message: types.Message,
# Auto-apply promo code if provided via start parameter
if promo_code_to_apply:
try:
from bot.services.promo_code_service import PromoCodeService
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
)
if success:
if success_bonus:
await session.commit()
logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}")
@@ -482,7 +493,7 @@ async def start_command_handler(message: types.Message,
connect_button_url = active.get("connect_button_url") if active else None
config_link_text = config_link_display or _("config_link_not_available")
new_end_date = result if isinstance(result, datetime) else None
new_end_date = bonus_result if isinstance(bonus_result, datetime) else None
promo_success_text = _(
"promo_code_applied_success_full",
@@ -505,10 +516,49 @@ async def start_command_handler(message: types.Message,
# Don't show main menu if promo was successfully applied
return
else:
await session.rollback()
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}")
# Continue to show main menu if promo failed
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()
discount_pct = discount_result if isinstance(discount_result, int) else 0
logging.info(
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:
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
@@ -552,7 +602,15 @@ async def verify_channel_subscription_callback(
welcome_text = _(key="welcome",
user_name=hd.quote(callback.from_user.full_name))
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:
fallback_bot: Optional[Bot] = getattr(callback, "bot", None)
if fallback_bot:
@@ -562,15 +620,22 @@ async def verify_channel_subscription_callback(
try:
await callback.answer(_(key="channel_subscription_verified_success"),
show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
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,
i18n_data,
subscription_service,
session,
is_edit=bool(callback.message))
is_edit=should_edit_menu_message)
@router.message(Command("language"))
@@ -663,6 +728,10 @@ async def main_action_callback_handler(
promo_code_service: PromoCodeService, session: AsyncSession):
action = callback.data.split(":")[1]
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 referral as user_referral_handlers
@@ -675,7 +744,7 @@ async def main_action_callback_handler(
if action == "subscribe":
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":
await user_subscription_handlers.my_subscription_command_handler(
callback, i18n_data, settings, panel_service, subscription_service,
@@ -685,6 +754,10 @@ async def main_action_callback_handler(
callback, i18n_data, settings, panel_service, subscription_service,
session, bot)
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(
callback, settings, i18n_data, referral_service, bot, session)
elif action == "apply_promo":
@@ -711,7 +784,4 @@ async def main_action_callback_handler(
session,
is_edit=False)
else:
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(
i18n_data.get("current_language"), key, **kw) if i18n else key
await callback.answer(_("main_menu_unknown_action"), show_alert=True)
+98 -58
View File
@@ -1,5 +1,6 @@
import hashlib
import logging
import math
from aiogram import Router, F, types, Bot
from aiogram.filters import Command
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]
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)
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):
try:
await event.answer(err_msg, show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
elif isinstance(event, types.Message):
await event.answer(err_msg)
return
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
currency_symbol_val = "RUB"
traffic_packages = getattr(settings, "traffic_packages", {}) or {}
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
options_are_stars = False
if traffic_mode:
if traffic_packages:
options = traffic_packages
elif stars_traffic_packages:
options = stars_traffic_packages
currency_symbol_val = ""
options_are_stars = True
else:
options = {}
else:
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")
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:
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):
try:
await event.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return
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)
try:
await event.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
else:
await target_message_obj.answer(text_content, reply_markup=reply_markup)
@router.callback_query(F.data == "main_action:subscribe")
async def reshow_subscription_options_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
await display_subscription_options(callback, i18n_data, settings, session)
async def reshow_subscription_options_callback(
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(
@@ -146,8 +186,8 @@ async def my_subscription_command_handler(
if isinstance(event, types.CallbackQuery):
try:
await event.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
try:
await event.message.edit_text(text, reply_markup=kb)
except Exception:
@@ -169,8 +209,8 @@ async def my_subscription_command_handler(
if isinstance(val, (int, float)):
val_gb = float(val) / (2**30)
return f"{val_gb:.2f} GB"
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return str(val)
if traffic_mode:
@@ -182,8 +222,8 @@ async def my_subscription_command_handler(
used_val = active.get("traffic_used_bytes") or 0
remaining_val = max(0, float(limit_val) - float(used_val))
remaining_display = _fmt_gb(remaining_val)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
text = get_text(
"my_traffic_details",
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
@@ -302,15 +342,15 @@ async def my_subscription_command_handler(
if prepend_rows:
kb = prepend_rows + kb
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
markup = InlineKeyboardMarkup(inline_keyboard=kb)
if isinstance(event, types.CallbackQuery):
try:
await event.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
try:
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
except Exception:
@@ -349,8 +389,8 @@ async def my_devices_command_handler(
if isinstance(event, types.CallbackQuery):
try:
await event.answer(get_text("my_devices_feature_disabled"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
else:
await target.answer(get_text("my_devices_feature_disabled"))
return
@@ -362,8 +402,8 @@ async def my_devices_command_handler(
if isinstance(event, types.CallbackQuery):
try:
await event.answer(message, show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
else:
await target.answer(message)
return
@@ -373,8 +413,8 @@ async def my_devices_command_handler(
if isinstance(event, types.CallbackQuery):
try:
await event.answer(get_text("no_devices_found"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
else:
await target.answer(get_text("no_devices_found"))
return
@@ -435,8 +475,8 @@ async def my_devices_command_handler(
if isinstance(event, types.CallbackQuery):
try:
await event.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
try:
await event.message.edit_text(text, reply_markup=markup)
except Exception:
@@ -462,8 +502,8 @@ async def disconnect_device_handler(
if not settings.MY_DEVICES_SECTION_ENABLED:
try:
await callback.answer(get_text("my_devices_feature_disabled"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return
try:
@@ -471,8 +511,8 @@ async def disconnect_device_handler(
except Exception:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
@@ -509,8 +549,8 @@ async def disconnect_device_handler(
await session.commit()
try:
await callback.answer(get_text("device_disconnected"))
except Exception:
pass
except Exception as exc:
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)
@@ -536,8 +576,8 @@ async def toggle_autorenew_handler(
except Exception:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return
sub = await session.get(Subscription, sub_id)
@@ -552,8 +592,8 @@ async def toggle_autorenew_handler(
if not has_saved_card:
try:
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return
# Show confirmation popup and inline buttons
@@ -564,12 +604,12 @@ async def toggle_autorenew_handler(
except Exception:
try:
await callback.message.answer(confirm_text, reply_markup=kb)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return
@@ -594,8 +634,8 @@ async def confirm_autorenew_handler(
except Exception:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return
sub = await session.get(Subscription, sub_id)
@@ -610,20 +650,20 @@ async def confirm_autorenew_handler(
if not has_saved_card:
try:
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
try:
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
await session.commit()
try:
await callback.answer(get_text("subscription_autorenew_updated"))
except Exception:
pass
except Exception as exc:
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)
@@ -647,21 +687,21 @@ async def autorenew_cancel_from_webhook_button(
if not sub:
try:
await callback.answer(get_text("subscription_not_active"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return
if sub.provider != "yookassa":
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": False})
await session.commit()
try:
await callback.answer(get_text("subscription_autorenew_updated"))
except Exception:
pass
except Exception as exc:
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)
@@ -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 typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,8 +27,8 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
return
_ = 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))
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
@router.callback_query(F.data == "pm:bind")
@@ -79,8 +80,8 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings,
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
return
_ = 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))
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
@router.callback_query(F.data.startswith("pm:delete_confirm"))
@@ -113,8 +114,8 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
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))
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
@router.callback_query(F.data.startswith("pm:delete"))
@@ -134,8 +135,8 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
parts = callback.data.split(":", 2)
@@ -156,8 +157,8 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
try:
legacy_deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id)
deleted = deleted or legacy_deleted
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
await session.commit()
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))
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
return
except Exception:
await session.rollback()
try:
await callback.answer(_("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
@router.callback_query(F.data.startswith("pm:view"))
@@ -208,8 +209,8 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
return
_ = 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()
if lp and lp.created_at:
last_tx = lp.created_at.strftime('%Y-%m-%d')
except Exception:
pass
except Exception as exc:
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)}"
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(str(sel.method_id), current_lang, i18n))
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
return
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()
if last_payment and last_payment.created_at:
last_tx = last_payment.created_at.strftime('%Y-%m-%d')
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
def _is_yoomoney_network(network: Optional[str]) -> bool:
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))
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
@router.callback_query(F.data.startswith("pm:history"))
@@ -329,8 +330,8 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
return
_ = 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:
filtered.append(p)
continue
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
user_payments = filtered
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))
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
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 aiogram import F, Router, types
@@ -11,6 +12,8 @@ from config.settings import Settings
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:"))
async def pay_crypto_callback_handler(
callback: types.CallbackQuery,
@@ -18,6 +21,7 @@ async def pay_crypto_callback_handler(
i18n_data: dict,
session: AsyncSession,
cryptopay_service: CryptoPayService,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -26,31 +30,68 @@ async def pay_crypto_callback_handler(
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
return
if not cryptopay_service or not getattr(cryptopay_service, "configured", False):
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
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"
except (ValueError, IndexError):
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
return
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}"
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
@@ -65,6 +106,7 @@ async def pay_crypto_callback_handler(
amount=price_amount,
description=payment_description,
sale_mode=sale_mode,
promo_code_service=promo_code_service,
)
if invoice_url:
@@ -101,15 +143,15 @@ async def pay_crypto_callback_handler(
),
disable_web_page_preview=False,
)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
return
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
except Exception as exc:
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")
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
@router.callback_query(F.data.startswith("pay_fk:"))
async def pay_fk_callback_handler(
callback: types.CallbackQuery,
@@ -21,6 +23,7 @@ async def pay_fk_callback_handler(
i18n_data: dict,
freekassa_service: FreeKassaService,
session: AsyncSession,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -29,53 +32,93 @@ async def pay_fk_callback_handler(
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
return
if not freekassa_service or not freekassa_service.configured:
logging.error("FreeKassa service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
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"
except (ValueError, IndexError):
logging.error(f"Invalid pay_fk data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
return
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}"
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_mode == "traffic"
else get_text("payment_description_subscription", months=int(months))
)
currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency_code = getattr(freekassa_service, "default_currency", None) or "RUB"
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"original_amount": None,
"discount_applied": None,
"currency": currency_code,
"status": "pending_freekassa",
"description": payment_description,
"subscription_duration_months": int(months),
"provider": "freekassa",
"promo_code_id": None,
}
try:
@@ -89,12 +132,12 @@ async def pay_fk_callback_handler(
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
return
success, response_data = await freekassa_service.create_order(
@@ -108,6 +151,8 @@ async def pay_fk_callback_handler(
extra_params={
"us_method": freekassa_service.payment_method_id,
},
promo_code_service=promo_code_service,
session=session,
)
if success:
@@ -173,12 +218,12 @@ async def pay_fk_callback_handler(
),
disable_web_page_preview=False,
)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
return
logging.error(
@@ -206,9 +251,9 @@ async def pay_fk_callback_handler(
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
@@ -14,6 +14,8 @@ from db.dal import payment_dal
router = Router(name="user_subscription_payments_platega_router")
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
@router.callback_query(F.data.startswith("pay_platega:"))
async def pay_platega_callback_handler(
callback: types.CallbackQuery,
@@ -21,6 +23,7 @@ async def pay_platega_callback_handler(
i18n_data: dict,
platega_service: PlategaService,
session: AsyncSession,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -29,53 +32,95 @@ async def pay_platega_callback_handler(
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
return
if not platega_service or not platega_service.configured:
logging.error("Platega service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
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"
except (ValueError, IndexError):
logging.error(f"Invalid pay_platega data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
return
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}"
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_mode == "traffic"
else get_text("payment_description_subscription", months=int(months))
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency_code = "RUB"
# Price is already discounted at payments_subscription.py stage
# Service will handle discount metadata if needed
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"original_amount": None,
"discount_applied": None,
"currency": currency_code,
"status": "pending_platega",
"description": payment_description,
"subscription_duration_months": int(months),
"provider": "platega",
"promo_code_id": None,
}
try:
@@ -89,12 +134,12 @@ async def pay_platega_callback_handler(
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
return
payload_meta = json.dumps(
@@ -114,6 +159,8 @@ async def pay_platega_callback_handler(
currency=currency_code,
description=payment_description,
payload=payload_meta,
promo_code_service=promo_code_service,
session=session,
)
if success:
@@ -175,12 +222,12 @@ async def pay_platega_callback_handler(
),
disable_web_page_preview=False,
)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
return
logging.error(
@@ -202,9 +249,9 @@ async def pay_platega_callback_handler(
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
except Exception as exc:
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")
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
@router.callback_query(F.data.startswith("pay_severpay:"))
async def pay_severpay_callback_handler(
callback: types.CallbackQuery,
@@ -20,6 +22,7 @@ async def pay_severpay_callback_handler(
i18n_data: dict,
severpay_service: SeverPayService,
session: AsyncSession,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -28,53 +31,95 @@ async def pay_severpay_callback_handler(
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
return
if not severpay_service or not severpay_service.configured:
logging.error("SeverPay service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
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"
except (ValueError, IndexError):
logging.error(f"Invalid pay_severpay data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
return
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}"
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_mode == "traffic"
else get_text("payment_description_subscription", months=int(months))
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency_code = "RUB"
# Price is already discounted at payments_subscription.py stage
# Service will handle discount metadata if needed
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"original_amount": None,
"discount_applied": None,
"currency": currency_code,
"status": "pending_severpay",
"description": payment_description,
"subscription_duration_months": int(months),
"provider": "severpay",
"promo_code_id": None,
}
try:
@@ -88,12 +133,12 @@ async def pay_severpay_callback_handler(
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
return
success, response_data = await severpay_service.create_payment(
@@ -103,6 +148,8 @@ async def pay_severpay_callback_handler(
amount=price_rub,
currency=currency_code,
description=payment_description,
promo_code_service=promo_code_service,
session=session,
)
if success:
@@ -164,12 +211,12 @@ async def pay_severpay_callback_handler(
),
disable_web_page_preview=False,
)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
return
logging.error(
@@ -191,9 +238,9 @@ async def pay_severpay_callback_handler(
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
except Exception as exc:
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,
session: AsyncSession,
stars_service: StarsService,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -27,15 +28,15 @@ async def pay_stars_callback_handler(
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
return
if not settings.STARS_ENABLED:
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
return
try:
@@ -47,8 +48,8 @@ async def pay_stars_callback_handler(
except (ValueError, IndexError):
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
return
user_id = callback.from_user.id
@@ -66,6 +67,7 @@ async def pay_stars_callback_handler(
stars_price=stars_price,
description=payment_description,
sale_mode=sale_mode,
promo_code_service=promo_code_service,
)
if payment_db_id:
@@ -76,34 +78,44 @@ async def pay_stars_callback_handler(
months=int(months),
traffic_gb=human_value,
),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=get_text("back_to_payment_methods_button"),
callback_data=f"subscribe_period:{human_value}",
)]
]),
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
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:
logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})")
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
return
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
@router.pre_checkout_query()
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try:
await query.answer(ok=True)
except Exception:
except Exception as exc:
# 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)
@@ -1,4 +1,5 @@
import logging
import math
from typing import Optional
from aiogram import F, Router, types
@@ -11,12 +12,43 @@ from config.settings import Settings
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:"))
async def select_subscription_period_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
promo_code_service=None, # Injected from dispatcher
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -25,8 +57,8 @@ async def select_subscription_period_callback_handler(
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
return
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}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
return
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)
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 traffic_mode and not price_source and stars_price is not None:
@@ -67,8 +141,8 @@ async def select_subscription_period_callback_handler(
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
return
price_rub = 0.0
currency_symbol_val = ""
@@ -78,11 +152,14 @@ async def select_subscription_period_callback_handler(
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
return
text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method")
if discount_text:
text_content = f"{discount_text}\n\n{text_content}"
reply_markup = get_payment_method_keyboard(
months,
price_rub,
@@ -103,5 +180,5 @@ async def select_subscription_period_callback_handler(
await callback.message.answer(text_content, reply_markup=reply_markup)
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
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.services.yookassa_service import YooKassaService
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")
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
def _format_value(val: float) -> str:
return str(int(val)) if float(val).is_integer() else f"{val:g}"
@@ -60,6 +62,7 @@ async def _initiate_yk_payment(
settings: Settings,
session: AsyncSession,
yookassa_service: YooKassaService,
promo_code_service, # NEW: Added promo_code_service
i18n: Optional[JsonI18n],
current_lang: str,
get_text,
@@ -77,6 +80,46 @@ async def _initiate_yk_payment(
if not callback.message:
return False
# Check for active discount to save metadata (price already discounted from previous step)
original_price = None
discount_amount = None
active_promo_code_id = None
if promo_code_service:
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
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 = (
get_text("payment_description_traffic", traffic_gb=_format_value(months))
if sale_mode == "traffic"
@@ -84,11 +127,14 @@ async def _initiate_yk_payment(
)
payment_record_data = {
"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,
"status": "pending_yookassa",
"description": payment_description,
"subscription_duration_months": int(months),
"promo_code_id": active_promo_code_id, # NEW: Link to promo code
}
db_payment_record = None
@@ -106,15 +152,15 @@ async def _initiate_yk_payment(
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return False
if not db_payment_record:
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return False
yookassa_metadata = {
@@ -178,8 +224,8 @@ async def _initiate_yk_payment(
card_network=display_network,
set_default=save_payment_method,
)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
await session.commit()
except Exception:
await session.rollback()
@@ -207,8 +253,8 @@ async def _initiate_yk_payment(
)
try:
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return False
try:
@@ -247,8 +293,8 @@ async def _initiate_yk_payment(
),
disable_web_page_preview=False,
)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return True
if payment_response_yk and payment_method_id:
@@ -276,8 +322,8 @@ async def _initiate_yk_payment(
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return False
message_text = get_text("yookassa_autopay_charge_initiated")
@@ -293,8 +339,8 @@ async def _initiate_yk_payment(
message_text,
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return True
try:
@@ -313,13 +359,13 @@ async def _initiate_yk_payment(
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return False
@router.callback_query(F.data.startswith("pay_yk:"))
async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -327,8 +373,8 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
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"))
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
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}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
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}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
months, price_rub, sale_mode = parsed
months, callback_price_rub, sale_mode = parsed
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"
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
autopay_require_binding = bool(
@@ -404,12 +488,12 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
sale_mode=sale_mode,
),
)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
await _initiate_yk_payment(
@@ -417,6 +501,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
settings=settings,
session=session,
yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
@@ -430,12 +515,12 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
)
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
@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)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
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:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service unavailable for pay_yk_new.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
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}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
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}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
months, price_rub, sale_mode = parsed
months, callback_price_rub, sale_mode = parsed
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"
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
autopay_require_binding = bool(
@@ -491,6 +613,7 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
settings=settings,
session=session,
yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
@@ -504,12 +627,12 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
)
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
@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)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
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:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
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}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
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}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
try:
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
sale_mode = parts[3] if len(parts) > 3 else "subscription"
except (ValueError, IndexError):
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
if not autopay_enabled:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
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:
saved_methods = await user_billing_dal.list_user_payment_methods(
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,
),
)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
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,
),
)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
@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)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
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:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service unavailable for pay_yk_use_saved.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
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}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
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}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
try:
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"
except (ValueError, IndexError):
logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
if not autopay_enabled:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
method_identifier = parts[2]
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:
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}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
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"
await _initiate_yk_payment(
@@ -752,6 +948,7 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
settings=settings,
session=session,
yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
@@ -767,5 +964,5 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
)
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
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:
try:
await callback.answer(_("error_occurred_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
return
show_trial_btn_in_menu_if_fail = False
@@ -53,8 +53,8 @@ async def request_trial_confirmation_handler(
)
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
return
if await subscription_service.has_had_any_subscription(session, user_id):
@@ -66,8 +66,8 @@ async def request_trial_confirmation_handler(
)
try:
await callback.answer()
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
return
# Directly activate trial without confirmation
@@ -84,8 +84,8 @@ async def request_trial_confirmation_handler(
if activation_result and activation_result.get("activated"):
try:
await callback.answer(_("trial_activated_alert"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
end_date_obj = activation_result.get("end_date")
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)
try:
await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
if (
settings.TRIAL_ENABLED
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:
try:
await callback.answer(_("error_occurred_try_again"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
return
if not settings.TRIAL_ENABLED:
try:
await callback.answer(_("trial_feature_disabled"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
@@ -216,8 +216,8 @@ async def confirm_activate_trial_handler(
await callback.answer(
_("trial_already_had_subscription_or_trial"), show_alert=True
)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
await send_main_menu(
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"):
try:
await callback.answer(_("trial_activated_alert"), show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
end_date_obj = activation_result.get("end_date")
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)
try:
await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
if (
settings.TRIAL_ENABLED
and not await subscription_service.has_had_any_subscription(
+7 -4
View File
@@ -28,13 +28,16 @@ def get_main_menu_inline_keyboard(
)
)
referral_button = InlineKeyboardButton(
text=_(key="menu_referral_inline"),
callback_data="main_action:referral")
promo_button = InlineKeyboardButton(
text=_(key="menu_apply_promo_button"),
callback_data="main_action:apply_promo")
builder.row(referral_button, promo_button)
if settings.REFERRAL_ENABLED:
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(
text=_(key="menu_language_settings_inline"),
+45 -37
View File
@@ -58,52 +58,48 @@ async def on_startup_configured(dispatcher: Dispatcher):
telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
if telegram_webhook_url_to_set:
full_telegram_webhook_url = (
f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
)
full_telegram_webhook_url = settings.telegram_full_webhook_url
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(
f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url if full_telegram_webhook_url != 'ERROR_URL_TOKEN_DETECTED' else 'HIDDEN DUE TO TOKEN'}"
"STARTUP: Attempting to set Telegram webhook (path=%s)",
settings.telegram_webhook_path,
)
if full_telegram_webhook_url != "ERROR_URL_TOKEN_DETECTED":
try:
current_webhook_info = await bot.get_webhook_info()
logging.info(
f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
)
try:
current_webhook_info = await bot.get_webhook_info()
if current_webhook_info.url:
logging.info("STARTUP: Telegram webhook already set (non-empty URL).")
else:
logging.info("STARTUP: Telegram webhook currently empty (will set).")
set_success = await bot.set_webhook(
url=full_telegram_webhook_url,
drop_pending_updates=True,
allowed_updates=dispatcher.resolve_used_update_types(),
)
if set_success:
logging.info(
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned SUCCESS (True)."
)
else:
logging.error(
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned FAILURE (False)."
)
telegram_webhook_secret = (settings.TELEGRAM_WEBHOOK_SECRET or "").strip() or None
set_success = await bot.set_webhook(
url=full_telegram_webhook_url,
drop_pending_updates=True,
allowed_updates=dispatcher.resolve_used_update_types(),
secret_token=telegram_webhook_secret,
)
if set_success:
logging.info("STARTUP: bot.set_webhook returned SUCCESS (True).")
else:
logging.error("STARTUP: bot.set_webhook returned FAILURE (False).")
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:
logging.error(
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity."
)
except Exception as e_setwebhook:
new_webhook_info = await bot.get_webhook_info()
if not new_webhook_info.url:
logging.error(
f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",
exc_info=True,
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity."
)
else:
except Exception as e_setwebhook:
logging.error(
"STARTUP: Skipped setting Telegram webhook due to security or configuration error."
"STARTUP: EXCEPTION during set/get Telegram webhook: %s",
e_setwebhook,
exc_info=True,
)
else:
logging.error(
@@ -149,6 +145,18 @@ async def on_startup_configured(dispatcher: Dispatcher):
except Exception as e:
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
try:
logging.info("STARTUP: Running automatic panel sync...")
+9 -8
View File
@@ -40,13 +40,14 @@ class ActionLoggerMiddleware(BaseMiddleware):
is_admin_event_flag = True
raw_update_snippet = None
try:
raw_update_snippet = event.model_dump_json(exclude_none=True,
indent=None)[:1000]
except AttributeError:
raw_update_snippet = str(event)[:1000]
except Exception:
raw_update_snippet = str(event)[:1000]
if self.settings.LOG_STORE_RAW_UPDATES:
try:
raw_update_snippet = event.model_dump_json(exclude_none=True,
indent=None)[:1000]
except AttributeError:
raw_update_snippet = str(event)[:1000]
except Exception:
raw_update_snippet = str(event)[:1000]
current_event_type = event.event_type
@@ -83,7 +84,7 @@ class ActionLoggerMiddleware(BaseMiddleware):
"telegram_username": telegram_username,
"telegram_first_name": telegram_first_name,
"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,
"is_admin_event": is_admin_event_flag,
"target_user_id": target_user_id_for_log,
+5 -2
View File
@@ -32,6 +32,9 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
event: Update,
data: Dict[str, Any],
) -> Any:
if not self.settings.REQUIRED_CHANNEL_SUBSCRIBE_TO_USE:
return await handler(event, data)
required_channel_id = self.settings.REQUIRED_CHANNEL_ID
if not required_channel_id:
return await handler(event, data)
@@ -124,8 +127,8 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
) -> None:
try:
await callback.answer(prompt_text, show_alert=True)
except Exception:
pass
except Exception as exc:
logging.debug("Failed to answer callback for channel gate prompt: %s", exc)
if callback.message:
try:
+119 -4
View File
@@ -65,23 +65,68 @@ class CryptoPayService:
amount: float,
description: str,
sale_mode: str = "subscription",
promo_code_service=None,
) -> Optional[str]:
if not self.configured or not self.client:
logging.error("CryptoPayService not configured")
return None
# Check for active discount to save metadata (price already discounted from previous step)
original_amount = None
discount_amount = None
promo_code_id = None
if promo_code_service:
from db.dal import active_discount_dal
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
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
try:
payment_record = await payment_dal.create_payment_record(
session,
{
"user_id": user_id,
"amount": float(amount),
"amount": amount,
"original_amount": original_amount,
"discount_applied": discount_amount,
"currency": self.settings.CRYPTOPAY_ASSET,
"status": "pending_cryptopay",
"description": description,
"subscription_duration_months": int(months),
"provider": "cryptopay",
"promo_code_id": promo_code_id,
},
)
await session.commit()
@@ -153,22 +198,92 @@ class CryptoPayService:
async with async_session_factory() as session:
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,
payment_db_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(
session,
user_id,
int(months) if sale_mode != "traffic" else 0,
float(invoice.amount),
payment_db_id,
promo_code_id_from_payment=payment_record.promo_code_id,
provider="cryptopay",
sale_mode=sale_mode,
traffic_gb=traffic_gb if sale_mode == "traffic" else None,
)
if not activation or not activation.get("end_date"):
raise RuntimeError(
f"CryptoPay webhook: activation failed for payment {payment_db_id}"
)
referral_bonus = None
if sale_mode != "traffic":
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
@@ -252,7 +367,7 @@ class CryptoPayService:
await notification_service.notify_payment_received(
user_id=user_id,
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,
traffic_gb=traffic_gb if sale_mode == "traffic" else None,
payment_provider="crypto_pay",
+92 -7
View File
@@ -44,7 +44,7 @@ class FreeKassaService:
self.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID
self.api_key: Optional[str] = settings.FREEKASSA_API_KEY
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.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID
@@ -78,11 +78,70 @@ class FreeKassaService:
ip_address: Optional[str] = None,
payment_method_id: Optional[int] = None,
extra_params: Optional[Dict[str, Any]] = None,
promo_code_service=None,
session=None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("FreeKassaService is not configured. Cannot create order.")
return False, {"message": "service_not_configured"}
# Check for active discount to save metadata (price already discounted from previous step)
original_amount = None
discount_amount = None
promo_code_id = None
if promo_code_service and session:
from db.dal import active_discount_dal
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
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
if not ip_address:
logging.error("FreeKassaService: payment IP is required but not configured.")
@@ -179,7 +238,10 @@ class FreeKassaService:
if self.shop_id and self.second_secret:
signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}"
expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest()
expected_signature = hashlib.md5(
signature_source.encode("utf-8"),
usedforsecurity=False,
).hexdigest()
if expected_signature.lower() == provided_signature.lower():
return True
@@ -258,6 +320,16 @@ class FreeKassaService:
logging.error(f"FreeKassa webhook: payment {payment_db_id} 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":
logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded")
return web.Response(text="YES")
@@ -267,22 +339,30 @@ class FreeKassaService:
amount_decimal = Decimal(amount_str)
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:
logging.warning(
logging.error(
f"FreeKassa webhook: amount mismatch for payment {payment_db_id} "
f"(expected {expected_amount}, got {amount_decimal})"
)
return web.Response(status=400, text="amount_mismatch")
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
referral_bonus = None
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,
payment_db_id=payment.payment_id,
provider_payment_id=str(provider_payment_id or f"freekassa:{order_id_str}"),
new_status="succeeded",
provider_payment_id=provider_id,
)
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
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
@@ -293,10 +373,15 @@ class FreeKassaService:
int(months) if sale_mode != "traffic" else 0,
float(payment.amount),
payment.payment_id,
promo_code_id_from_payment=payment.promo_code_id,
provider="freekassa",
sale_mode=sale_mode,
traffic_gb=months if sale_mode == "traffic" else None,
)
if not activation or not activation.get("end_date"):
raise RuntimeError(
f"FreeKassa webhook: activation failed for payment {payment.payment_id}"
)
referral_bonus = None
if sale_mode != "traffic":
+30 -4
View File
@@ -271,15 +271,15 @@ class NotificationService:
"""Send notification about promo code activation"""
if not self.settings.LOG_PROMO_ACTIVATIONS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = self._format_user_display(
user_id=user_id,
username=username,
)
message = _(
"log_promo_activation",
user_display=user_display,
@@ -287,7 +287,33 @@ class NotificationService:
bonus_days=bonus_days,
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
profile_keyboard = self._build_profile_keyboard(_, user_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
+38 -7
View File
@@ -59,6 +59,29 @@ class PanelApiService:
headers["Authorization"] = f"Bearer {self.api_key}"
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,
method: str,
endpoint: str,
@@ -83,8 +106,8 @@ class PanelApiService:
if current_params:
try:
url_with_params_for_log += "?" + urlencode(current_params)
except Exception:
pass
except Exception as exc:
logging.debug("Failed to encode params for panel API log URL: %s", exc)
json_payload_for_log = kwargs.get('json') if method.upper() in [
"POST", "PATCH", "PUT"
@@ -92,10 +115,11 @@ class PanelApiService:
log_prefix = f"Panel API Req: {method.upper()} {url_with_params_for_log}"
if json_payload_for_log:
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 ''}"
except Exception:
log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..."
log_prefix += " | Payload: <unavailable>"
try:
async with aiohttp_session.request(method.upper(),
url_for_request,
@@ -106,7 +130,8 @@ class PanelApiService:
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:
parsed_json_for_log = json.loads(response_text)
pretty_response_text = json.dumps(parsed_json_for_log,
@@ -404,7 +429,10 @@ class PanelApiService:
return response
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
@@ -426,7 +454,10 @@ class PanelApiService:
return full_response.get("response")
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
+13 -10
View File
@@ -136,16 +136,19 @@ class PanelWebhookService:
)
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
if self.settings.PANEL_WEBHOOK_SECRET:
if not signature_header:
return web.Response(status=403, text="no_signature")
expected_sig = hmac.new(
self.settings.PANEL_WEBHOOK_SECRET.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected_sig, signature_header):
return web.Response(status=403, text="invalid_signature")
if not self.settings.PANEL_WEBHOOK_SECRET:
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:
return web.Response(status=403, text="no_signature")
expected_sig = hmac.new(
self.settings.PANEL_WEBHOOK_SECRET.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected_sig, signature_header):
return web.Response(status=403, text="invalid_signature")
try:
payload = json.loads(raw_body.decode())
+94 -11
View File
@@ -76,14 +76,73 @@ class PlategaService:
currency: Optional[str],
description: str,
payload: Optional[str] = None,
promo_code_service=None,
session=None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("PlategaService is not configured. Cannot create transaction.")
return False, {"message": "service_not_configured"}
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"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
currency_code = (currency or "RUB").upper()
body: Dict[str, Any] = {
"paymentMethod": int(self.payment_method),
@@ -98,7 +157,7 @@ class PlategaService:
clean_body = {k: v for k, v in body.items() if v not in (None, "")}
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()
try:
response_data = json.loads(response_text) if response_text else {}
@@ -142,7 +201,7 @@ class PlategaService:
transaction_id = str(data.get("id") or data.get("transactionId") or "").strip()
status = str(data.get("status") or "").upper()
amount_raw = data.get("amount")
currency = data.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency = data.get("currency") or "RUB"
if not transaction_id or not status:
logging.error("Platega webhook: missing transaction id or status in payload: %s", data)
@@ -161,27 +220,46 @@ class PlategaService:
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
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:
try:
incoming_amount = Decimal(str(amount_raw)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if incoming_amount != expected_amount:
logging.warning(
logging.error(
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)",
payment.payment_id,
expected_amount,
incoming_amount,
)
return web.Response(status=400, text="amount_mismatch")
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:
await payment_dal.update_provider_payment_and_status(
marked = await payment_dal.mark_provider_payment_succeeded_once(
session,
payment.payment_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(
session,
@@ -189,10 +267,15 @@ class PlategaService:
int(payment_months) if sale_mode != "traffic" else 0,
float(payment.amount),
payment.payment_id,
promo_code_id_from_payment=payment.promo_code_id,
provider="platega",
sale_mode=sale_mode,
traffic_gb=payment_months if sale_mode == "traffic" else None,
)
if not activation or not activation.get("end_date"):
raise RuntimeError(
f"Platega webhook: activation failed for payment {payment.payment_id}"
)
referral_bonus = None
if sale_mode != "traffic":
@@ -305,7 +388,7 @@ class PlategaService:
return web.Response(text="ok")
if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}:
if status in {"CANCELED", "CANCELLED", "CHARGEBACK", "CHARGEBACKED"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
@@ -324,8 +407,8 @@ class PlategaService:
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
try:
await self.bot.send_message(payment.user_id, _("payment_failed"))
except Exception:
pass
except Exception as exc:
logging.debug("Platega webhook: failed to send cancellation message to user %s: %s", payment.user_id, exc)
return web.Response(text="ok_canceled")
logging.warning("Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id)
+404 -5
View File
@@ -1,13 +1,14 @@
import logging
from datetime import datetime
import asyncio
from datetime import datetime, timezone, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional, Tuple, Dict
from typing import Optional, Tuple
from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
from db.dal import promo_code_dal, user_dal
from db.models import PromoCode, User
from db.dal import promo_code_dal, user_dal, active_discount_dal, payment_dal
from .subscription_service import SubscriptionService
from bot.middlewares.i18n import JsonI18n
@@ -23,6 +24,122 @@ class PromoCodeService:
self.subscription_service = subscription_service
self.bot = bot
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
async def setup_discount_expiration_worker(
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(
self,
@@ -34,7 +151,7 @@ class PromoCodeService:
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = code_input.strip().upper()
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
promo_data = await promo_code_dal.get_active_bonus_promo_code_by_code_str(
session, code_input_upper)
if not promo_data:
@@ -83,3 +200,285 @@ class PromoCodeService:
return False, _("error_applying_promo_bonus")
else:
return False, _("error_applying_promo_bonus")
async def apply_discount_promo_code(
self,
session: AsyncSession,
user_id: int,
code_input: str,
user_lang: str,
) -> Tuple[bool, int | str]:
"""
Apply a discount promo code (sets active discount for user).
Returns: (success: bool, discount_percentage or error_message)
"""
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = code_input.strip().upper()
# Check if user already has an active discount
existing_discount = await active_discount_dal.get_active_discount(
session,
user_id,
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,
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_bonus_applied_days: Optional[int] = None
inviter_bonus_successfully_applied = False
@@ -260,6 +267,9 @@ class ReferralService:
async def generate_referral_link(self, session: AsyncSession,
bot_username: str,
inviter_user_id: int) -> Optional[str]:
if not getattr(self.settings, "REFERRAL_ENABLED", True):
return None
try:
user = await user_dal.get_user_by_id(session, inviter_user_id)
if not user:
+112 -8
View File
@@ -99,14 +99,73 @@ class SeverPayService:
amount: float,
currency: Optional[str],
description: str,
promo_code_service=None,
session=None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("SeverPayService is not configured. Cannot create payment.")
return False, {"message": "service_not_configured"}
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"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
currency_code = (currency or "RUB").upper()
amount_str = self._format_amount(amount)
body = {
@@ -124,7 +183,7 @@ class SeverPayService:
signed_body = self._build_signed_body(body)
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()
try:
response_data = json.loads(response_text) if response_text else {}
@@ -169,6 +228,8 @@ class SeverPayService:
provider_payment_id = str(data.get("id") or data.get("uid") or "")
order_id_raw = data.get("order_id")
status = str(data.get("status") or "").lower()
amount_raw = data.get("amount")
currency_raw = data.get("currency")
payment_db_id: Optional[int] = None
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)
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
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
if status == "success":
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,
payment.payment_id,
provider_payment_id or str(payment.payment_id),
"succeeded",
provider_id,
)
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(
session,
@@ -207,6 +310,7 @@ class SeverPayService:
int(payment_months) if sale_mode != "traffic" else 0,
float(payment.amount),
payment.payment_id,
promo_code_id_from_payment=payment.promo_code_id,
provider="severpay",
sale_mode=sale_mode,
traffic_gb=payment_months if sale_mode == "traffic" else None,
@@ -342,8 +446,8 @@ class SeverPayService:
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
try:
await self.bot.send_message(payment.user_id, _("payment_failed"))
except Exception:
pass
except Exception as exc:
logging.debug("SeverPay webhook: failed to send cancellation message to user %s: %s", payment.user_id, exc)
return web.json_response({"status": True})
if status in {"process", "new"}:
+129 -34
View File
@@ -1,4 +1,5 @@
import logging
import math
from typing import Optional
from aiogram import Bot, types
@@ -26,16 +27,94 @@ class StarsService:
self.subscription_service = subscription_service
self.referral_service = referral_service
async def create_invoice(self, session: AsyncSession, user_id: int, months: int,
stars_price: int, description: str, sale_mode: str = "subscription") -> Optional[int]:
def _resolve_base_stars_price(self, months: float, sale_mode: str) -> 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 = {
"user_id": user_id,
"amount": float(stars_price),
"original_amount": float(original_stars_price) if discount_amount_stars else None,
"discount_applied": float(discount_amount_stars) if discount_amount_stars else None,
"currency": "XTR",
"status": "pending_stars",
"description": description,
"subscription_duration_months": int(months),
"provider": "telegram_stars",
"promo_code_id": promo_code_id,
}
try:
db_payment_record = await payment_dal.create_payment_record(
@@ -55,7 +134,7 @@ class StarsService:
title=description,
description=description,
payload=payload,
provider_token="",
provider_token=self.settings.STARS_PROVIDER_TOKEN or "",
currency="XTR",
prices=prices,
)
@@ -72,45 +151,61 @@ class StarsService:
stars_amount: int,
i18n_data: dict,
sale_mode: str = "subscription") -> None:
# Fetch payment record to get promo_code_id
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
promo_code_id_from_payment = payment_record.promo_code_id if payment_record else None
activation_details = None
referral_bonus = None
try:
await payment_dal.update_provider_payment_and_status(
session, payment_db_id,
message.successful_payment.provider_payment_charge_id,
"succeeded")
provider_payment_id = str(
message.successful_payment.provider_payment_charge_id
or f"stars:{payment_db_id}"
)
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()
except Exception as e_upd:
await session.rollback()
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)
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
final_end = referral_bonus.get("referee_new_end_date") if referral_bonus else None
if not final_end:
+43 -4
View File
@@ -5,7 +5,7 @@ from typing import Optional, Dict, Any, List, Tuple
from aiogram import Bot
from bot.middlewares.i18n import JsonI18n
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal
from db.dal import user_dal, subscription_dal, promo_code_dal, user_billing_dal, payment_dal
from bot.utils.date_utils import add_months
from bot.utils.config_link import prepare_config_links
from db.models import User, Subscription
@@ -604,7 +604,7 @@ class SubscriptionService:
and promo_model.is_active
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
activation = await promo_code_dal.record_promo_activation(
@@ -615,7 +615,7 @@ class SubscriptionService:
)
if activation:
await promo_code_dal.increment_promo_code_usage(
session, promo_code_id_from_payment
session, promo_code_id_from_payment, allow_overflow=True
)
else:
logging.warning(
@@ -691,6 +691,22 @@ class SubscriptionService:
final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
# Consume discount promo code if payment had one
try:
promo_code_service = getattr(self, "promo_code_service", None)
if not promo_code_service:
from .promo_code_service import PromoCodeService
promo_code_service = PromoCodeService(
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 {
"subscription_id": new_or_updated_sub.subscription_id,
"end_date": final_end_date,
@@ -991,15 +1007,30 @@ class SubscriptionService:
logging.error(f"Auto-renew price missing for {months} months")
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 = {
"user_id": str(sub.user_id),
"auto_renew_for_subscription_id": str(sub.subscription_id),
"subscription_months": str(months),
"payment_db_id": str(payment_record.payment_id),
}
resp = await yk.create_payment(
amount=float(amount),
currency="RUB",
description=f"Auto-renewal for {months} months",
description=payment_description,
metadata=metadata,
payment_method_id=default_pm.provider_payment_method_id,
save_payment_method=False,
@@ -1008,6 +1039,14 @@ class SubscriptionService:
if not resp or resp.get("status") not in {"pending", "waiting_for_capture", "succeeded"}:
logging.error(f"Auto-renew create_payment failed: {resp}")
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')}")
return True
+2
View File
@@ -6,8 +6,10 @@ class AdminStates(StatesGroup):
waiting_for_broadcast_message = State()
confirming_broadcast = State()
waiting_for_promo_details = State()
waiting_for_promo_type_selection = State()
waiting_for_promo_code = State()
waiting_for_promo_bonus_days = State()
waiting_for_promo_discount_percentage = State()
waiting_for_promo_max_activations = State()
waiting_for_promo_validity_days = State()
waiting_for_promo_edit_details = State()
+107 -9
View File
@@ -1,6 +1,6 @@
import logging
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field, ValidationError, computed_field, field_validator
from pydantic import Field, ValidationError, computed_field, field_validator, model_validator
from typing import Optional, List, Dict, Any
@@ -18,11 +18,14 @@ class Settings(BaseSettings):
POSTGRES_DB: str = Field(default="vpn_shop_db")
DEFAULT_LANGUAGE: str = Field(default="ru")
DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB")
SUPPORT_LINK: Optional[str] = Field(default=None)
SERVER_STATUS_URL: Optional[str] = Field(default=None)
TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
REQUIRED_CHANNEL_SUBSCRIBE_TO_USE: bool = Field(
default=False,
description="Require users to subscribe to REQUIRED_CHANNEL_ID before using the bot",
)
REQUIRED_CHANNEL_ID: Optional[int] = Field(
default=None,
description="Telegram channel ID the user must join to access the bot")
@@ -73,6 +76,14 @@ class Settings(BaseSettings):
)
WEBHOOK_BASE_URL: Optional[str] = None
TELEGRAM_WEBHOOK_PATH: str = Field(
default="/webhook/telegram",
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_NETWORK: str = Field(default="mainnet")
@@ -110,6 +121,10 @@ class Settings(BaseSettings):
YOOKASSA_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(
default=None,
description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)",
@@ -168,6 +183,10 @@ class Settings(BaseSettings):
default=True,
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user - on their first successful payment."
)
REFERRAL_ENABLED: bool = Field(
default=True,
description="Enable referral links, referral menu and referral bonuses",
)
LEGACY_REFS: bool = Field(
default=True,
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset."
@@ -279,6 +298,22 @@ class Settings(BaseSettings):
return cleaned
return None
@computed_field
@property
def telegram_webhook_path(self) -> str:
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
@property
def yookassa_webhook_path(self) -> str:
@@ -520,7 +555,24 @@ class Settings(BaseSettings):
)
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_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')
@classmethod
def normalize_log_level(cls, v):
@@ -530,13 +582,39 @@ class Settings(BaseSettings):
return "INFO"
return v
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before')
@model_validator(mode='before')
@classmethod
def validate_optional_int_fields(cls, v):
"""Convert empty strings to None for optional integer fields"""
if isinstance(v, str) and v.strip() == '':
return None
return v
def drop_comment_placeholder_values(cls, values: Any):
"""
dotenv parses lines like `KEY= # comment` as `"# comment"`.
Treat such values as unset so defaults/optionals work as expected.
"""
if not isinstance(values, dict):
return values
sanitized: Dict[str, Any] = {}
for key, value in values.items():
if isinstance(value, str):
trimmed = value.strip()
if trimmed == "#" or trimmed.startswith("# "):
continue
sanitized[key] = value
return sanitized
@field_validator(
'TELEGRAM_WEBHOOK_PATH',
mode='before',
)
@classmethod
def normalize_webhook_path(cls, v):
if not isinstance(v, str):
return "/webhook/telegram"
cleaned = v.strip()
if not cleaned:
return "/webhook/telegram"
if not cleaned.startswith("/"):
cleaned = f"/{cleaned}"
return cleaned
@field_validator(
'REQUIRED_CHANNEL_LINK',
@@ -544,6 +622,8 @@ class Settings(BaseSettings):
'PLATEGA_FAILED_URL',
'SEVERPAY_RETURN_URL',
'CRYPT4_REDIRECT_URL',
'TELEGRAM_WEBHOOK_SECRET',
'PANEL_WEBHOOK_SECRET',
mode='before',
)
@classmethod
@@ -552,7 +632,16 @@ class Settings(BaseSettings):
return None
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
def validate_optional_int(cls, v):
if isinstance(v, str):
@@ -567,6 +656,10 @@ class Settings(BaseSettings):
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_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',
env_file_encoding='utf-8',
@@ -591,6 +684,11 @@ def get_settings() -> Settings:
logging.warning(
"CRITICAL: PANEL_API_URL is not set. Panel integration will not work."
)
if _settings_instance.WEBHOOK_BASE_URL and not _settings_instance.TELEGRAM_WEBHOOK_SECRET:
logging.warning(
"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 _settings_instance.YOOKASSA_SHOP_ID or not _settings_instance.YOOKASSA_SECRET_KEY:
logging.warning(
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
+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.")
+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
async def get_all_message_logs(session: AsyncSession, limit: int,
offset: int) -> List[MessageLog]:
stmt = select(MessageLog).order_by(
MessageLog.timestamp.desc()).limit(limit).offset(offset)
async def get_all_message_logs(session: AsyncSession,
limit: int,
offset: int,
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)
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)
if hide_admin_events:
stmt = stmt.where(MessageLog.is_admin_event.is_(False))
result = await session.execute(stmt)
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.orm import selectinload
from db.models import Payment, User
from db.models import Payment
async def create_payment_record(session: AsyncSession,
@@ -176,10 +176,149 @@ async def update_provider_payment_and_status(
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]:
"""Get comprehensive financial statistics."""
from datetime import datetime, timedelta
from sqlalchemy import and_, text
from sqlalchemy import and_
now = datetime.utcnow()
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()
async def get_active_bonus_promo_code_by_code_str(
session: AsyncSession, code_str: str) -> Optional[PromoCode]:
"""Get active bonus_days-type promo code by code string"""
stmt = select(PromoCode).where(
PromoCode.code == code_str.upper(),
PromoCode.promo_type == "bonus_days",
PromoCode.is_active == True,
PromoCode.current_activations < PromoCode.max_activations,
or_(PromoCode.valid_until == None, PromoCode.valid_until
> datetime.now(timezone.utc)))
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_active_discount_promo_code_by_code_str(
session: AsyncSession, code_str: str) -> Optional[PromoCode]:
"""Get active discount-type promo code by code string"""
stmt = select(PromoCode).where(
PromoCode.code == code_str.upper(),
PromoCode.promo_type == "discount",
PromoCode.is_active == True,
PromoCode.current_activations < PromoCode.max_activations,
or_(PromoCode.valid_until == None, PromoCode.valid_until
> datetime.now(timezone.utc)))
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_all_active_promo_codes(session: AsyncSession,
limit: int = 20,
offset: int = 0) -> List[PromoCode]:
@@ -104,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]:
from db.dal import active_discount_dal
promo = await get_promo_code_by_id(session, promo_id)
if not promo:
return None
# First, delete related activations due to foreign key constraint
# 1. Clear all active discounts referencing this promo code
await active_discount_dal.clear_active_discounts_by_promo_code(session, promo_id)
# 2. Set promo_code_id to NULL in payments table to avoid FK violation
stmt = update(Payment).where(Payment.promo_code_id == promo_id).values(promo_code_id=None)
await session.execute(stmt)
# 3. Delete related activations
activations = await get_promo_activations_by_code_id(session, promo_id)
for activation in activations:
await session.delete(activation)
# 4. Delete the promo code itself
await session.delete(promo)
await session.flush()
logging.info(f"Promo code '{promo.code}' (ID: {promo_id}) deleted successfully")
return promo
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)
if promo:
if promo.current_activations < promo.max_activations:
promo.current_activations += 1
await session.flush()
await session.refresh(promo)
return promo
if allow_overflow:
logging.warning(
f"Failed to increment promo usage for promo {promo.code} (ID: {promo_code_id})."
)
else:
logging.warning(
f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations."
)
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(
session: AsyncSession, promo_code_id: int,
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}"
)
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)
+14 -2
View File
@@ -344,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)
)
)
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(
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(
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(
delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
)
+21 -10
View File
@@ -1,20 +1,35 @@
import logging
from urllib.parse import urlsplit, urlunsplit
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
from .models import Base
from .migrator import run_database_migrations
from .alembic_runner import run_alembic_migrations
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:
global async_engine
if async_engine is None:
masked_url = _mask_db_url(settings.DATABASE_URL)
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(
settings.DATABASE_URL,
@@ -30,7 +45,7 @@ def init_db_connection(settings: Settings) -> sessionmaker:
autoflush=False,
)
logging.info(
f"SQLAlchemy Async Engine and SessionFactory configured for PostgreSQL."
"SQLAlchemy Async Engine and SessionFactory configured for PostgreSQL."
)
return local_async_session_factory
@@ -61,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 with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(run_database_migrations)
logging.info(
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
)
await run_alembic_migrations(settings, async_engine)
logging.info("PostgreSQL database migrations checked/applied via Alembic.")
async with session_factory() as session:
from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status
-172
View File
@@ -1,172 +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)
"""
)
)
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,
),
]
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)
+39 -2
View File
@@ -100,7 +100,12 @@ class Payment(Base):
provider_payment_id = Column(String, unique=True, nullable=True)
provider = Column(String, nullable=False, default="yookassa", index=True)
idempotence_key = Column(String, unique=True, nullable=True)
amount = Column(Float, nullable=False)
amount = Column(Float, nullable=False) # Final amount paid (after discount if any)
# Discount tracking fields
original_amount = Column(Float, nullable=True) # Amount before discount
discount_applied = Column(Float, nullable=True) # Discount amount (not percentage)
currency = Column(String, nullable=False)
status = Column(String, nullable=False, index=True)
description = Column(String, nullable=True)
@@ -154,7 +159,17 @@ class PromoCode(Base):
promo_code_id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String, unique=True, nullable=False, index=True)
bonus_days = Column(Integer, nullable=False)
# Type field to distinguish promo code types
promo_type = Column(String, nullable=False, default="bonus_days", index=True)
# Values: "bonus_days" or "discount"
# For bonus_days type: number of days to add to subscription
bonus_days = Column(Integer, nullable=True)
# For discount type: percentage discount (1-100)
discount_percentage = Column(Integer, nullable=True)
max_activations = Column(Integer, nullable=False)
current_activations = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
@@ -191,6 +206,28 @@ class PromoCodeActivation(Base):
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):
__tablename__ = "message_logs"
+32
View File
@@ -76,6 +76,11 @@
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
"promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"discount_promo_code_applied_success": "✅ Promo code <code>{code}</code> activated!\n\n💰 A {discount}% discount will be applied to your next purchase.\n\nSelect a plan for payment.",
"discount_promo_already_active": "❌ You already have an active discount promo code (<code>{code}</code>, -{discount_pct}%). Use it first or wait until the payment is complete.",
"discount_promo_expired_need_reactivate": "⏰ The discount hold period has ended{code_part}.\n\nPlease enter the promo code again to apply the discount.",
"promo_code_not_found_or_not_discount": "❌ Promo code <code>{code}</code> not found or is invalid.",
"active_discount_notice": "🎁 Active discount: <code>{code}</code> (-{discount_pct}%)\n💵 Price: <s>{original_price}{currency_symbol}</s> ➔ <b>{discounted_price}{currency_symbol}</b>\n💰 Savings: {discount_amount}{currency_symbol}",
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
"promo_input_cancelled_short": "Promo code entry cancelled.",
"trial_feature_disabled": "Free trial is currently unavailable.",
@@ -169,6 +174,7 @@
"admin_promo_invalid_max_activations": "Max activations must be a positive number.",
"admin_promo_invalid_validity_days": "Validity period (in days) must be a positive number.",
"admin_promo_created_success": "✅ Promo code <code>{code}</code> created successfully!\nBonus: {bonus_days} days\nMax uses: {max_activations}\nValid until: {valid_until_str}.",
"admin_promo_created_success_discount": "✅ Promo code <code>{code}</code> created successfully!\nDiscount: {discount_percentage}%\nMax uses: {max_activations}\nValid until: {valid_until_str}.",
"admin_promo_set_validity_days": "⏰ Set validity (days)",
"admin_back_to_panel": "⬅️ Back to panel",
"admin_promo_unlimited": "♾️ Unlimited",
@@ -203,11 +209,13 @@
"csv_no": "No",
"admin_promo_edit_select_field": "Select a field to edit:",
"admin_promo_prompt_bonus_days": "Enter the new number of bonus days:",
"admin_promo_prompt_discount_percentage": "Enter the new discount percentage (1-100):",
"admin_promo_prompt_max_activations": "Enter the new maximum number of activations:",
"admin_promo_prompt_validity_days": "Enter the new validity period in days (0 for indefinite):",
"admin_promo_edit_success": "Promo code updated successfully.",
"admin_promo_invalid_input": "Invalid input, please try again.",
"admin_promo_edit_bonus_days": "🎁 Bonus Days",
"admin_promo_edit_discount_percentage": "💰 Discount %",
"admin_promo_edit_max_activations": "🔢 Max Activations",
"admin_promo_edit_validity": "⏰ Validity",
"admin_ban_user_prompt": "Enter user ID or @username to ban:",
@@ -308,6 +316,7 @@
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_payment_received_traffic": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n🗂 Traffic: <b>{traffic_gb} GB</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
"log_promo_discount_activation": "💰 <b>Discount Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n💵 Discount: <b>{discount_percentage}%</b>\n🕐 Time: {timestamp}",
"log_trial_activation": "🆓 <b>Trial Activated</b>\n\n👤 User: {user_display}\n⏰ Valid until: <b>{end_date}</b>\n🕐 Time: {timestamp}",
"log_panel_sync": "{status_emoji} <b>Panel Synchronization</b>\n\n📊 Status: <b>{status}</b>\n👥 Users processed: <b>{users_processed}</b>\n📋 Subscriptions synced: <b>{subs_synced}</b>\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}",
"log_suspicious_promo": "⚠️ <b>Suspicious Promo Code Attempt</b>\n\n👤 User: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Input: <pre>{suspicious_input}</pre>\n🕐 Time: {timestamp}",
@@ -335,10 +344,16 @@
"admin_bulk_promo_invalid_quantity": "❌ Quantity must be between 1 and 1000",
"admin_bulk_promo_enter_validity_days": "⏰ Enter the number of validity days for promo codes (1-365):",
"admin_bulk_promo_creating": "⏳ Creating {quantity} promo codes...",
"admin_promo_step0_type": "Choose promo code type:",
"admin_promo_type_bonus_days": "🎁 Bonus Days (subscription extension)",
"admin_promo_type_discount": "💰 Purchase Discount (%)",
"admin_promo_step1_code": "🎟 <b>Create Promo Code</b>\n\n<b>Step 1 of 4:</b> Promo Code\n\nEnter promo code (3-30 characters, letters and numbers only):",
"admin_promo_step2_bonus_days": "🎟 <b>Create Promo Code</b>\n\n<b>Step 2 of 4:</b> Bonus Days\n\nCode: <b>{code}</b>\n\nEnter the number of bonus days (1-365):",
"admin_promo_step2_discount_percentage": "🎟 <b>Create Promo Code</b>\n\n<b>Step 2 of 4:</b> Discount Percentage\n\nCode: <b>{code}</b>\n\nEnter the discount percentage for the promo code (1-100):",
"admin_promo_step3_max_activations": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\n\nEnter the maximum number of activations (1-10000):",
"admin_promo_step3_max_activations_discount": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nDiscount: <b>{discount_percentage}%</b>\n\nEnter the maximum number of activations (1-10000):",
"admin_promo_step4_validity": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:",
"admin_promo_step4_validity_discount": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nDiscount: <b>{discount_percentage}%</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:",
"admin_promo_code_already_exists": "❌ A promo code with this code already exists",
"admin_promo_unlimited_validity": "♾️ Unlimited",
"admin_promo_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):",
@@ -373,7 +388,9 @@
"admin_promo_management_title": "🎟 <b>Promo Code Management</b>\n\nSelect a promo code for detailed view:",
"admin_promo_management_empty": "📭 No promo codes available",
"admin_promo_card_title": "🎟 <b>Promo Code: {code}</b>",
"admin_promo_card_type": "📌 Type: {type}",
"admin_promo_card_bonus_days": "🎁 Bonus days: <b>{days}</b>",
"admin_promo_card_discount_percentage": "💰 Discount: <b>{percentage}%</b>",
"admin_promo_card_activations": "🔢 Activations: <b>{current}/{max}</b>",
"admin_promo_card_validity": "⏰ Valid until: <b>{validity}</b>",
"admin_promo_card_status": "📊 Status: <b>{status}</b>",
@@ -486,5 +503,20 @@
"admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.",
"admin_ads_deleted_success": "Campaign deleted.",
"admin_ads_not_found": "Campaign not found.",
"admin_bulk_promo_creation_cancelled": "Bulk promo creation cancelled.",
"admin_direct_empty_message": "Cannot send an empty message.",
"admin_logs_csv_export_failed": "❌ CSV export failed: {error}",
"admin_logs_csv_no_data": "No data to export.",
"admin_promo_creation_cancelled": "Promo creation cancelled.",
"admin_promo_invalid_number": "❌ Invalid number. Enter a number greater than 0.",
"admin_sync_errors": "\n🚫 Synchronization errors: {count}",
"error_applying_promo_discount": "❌ Error applying promo discount. Please try again later.",
"error_generating_referral_link": "Error generating referral link. Please try again later.",
"error_occurred_processing_request": "An error occurred while processing your request. Please try again.",
"inline_panel_stats_error": "❌ Failed to load panel statistics.",
"inline_system_error": "Error loading statistics",
"no_bonus_placeholder": "none",
"payment_successful_with_promo_full": "✅ Payment successful!\nYour {months}-month subscription has been extended by {bonus_days} bonus days via promo code and is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"system_or_unknown_user": "System/unknown user",
"free_kassa_order_full": "Order #{order_id} from {date}\n\n"
}
+33
View File
@@ -76,7 +76,13 @@
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
"promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"discount_promo_code_applied_success": "✅ Промокод <code>{code}</code> активирован!\n\n💰 Скидка {discount}% будет применена к вашей следующей покупке.\n\nВыберите тариф для оплаты.",
"discount_promo_already_active": "❌ У вас уже есть активированный промокод на скидку (<code>{code}</code>, -{discount_pct}%). Используйте его сначала или дождитесь окончания платежа.",
"discount_promo_expired_need_reactivate": "⏰ Время действия скидки истекло{code_part}.\n\nВведите промокод снова, чтобы применить скидку.",
"promo_code_not_found_or_not_discount": "❌ Промокод <code>{code}</code> не найден или недействителен.",
"active_discount_notice": "🎁 Активна скидка: <code>{code}</code> (-{discount_pct}%)\n💵 Цена: <s>{original_price}{currency_symbol}</s> ➔ <b>{discounted_price}{currency_symbol}</b>\n💰 Экономия: {discount_amount}{currency_symbol}",
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
"error_applying_promo_discount": "❌ Ошибка при активации промокода. Попробуйте позже.",
"promo_input_cancelled_short": "Ввод промокода отменен.",
"trial_feature_disabled": "Пробный период в данный момент недоступен.",
"trial_already_had_subscription_or_trial": "Вы уже использовали пробный период или у вас была платная подписка. Пробный период доступен только один раз для новых пользователей.",
@@ -169,6 +175,7 @@
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
"admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.",
"admin_promo_created_success": "✅ Промокод <code>{code}</code> успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
"admin_promo_created_success_discount": "✅ Промокод <code>{code}</code> успешно создан!\nСкидка: {discount_percentage}%\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
"subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
@@ -212,11 +219,13 @@
"csv_no": "Нет",
"admin_promo_edit_select_field": "Выберите поле для редактирования:",
"admin_promo_prompt_bonus_days": "Введите новое количество бонусных дней:",
"admin_promo_prompt_discount_percentage": "Введите новый процент скидки (от 1 до 100):",
"admin_promo_prompt_max_activations": "Введите новое максимальное количество активаций:",
"admin_promo_prompt_validity_days": "Введите новый срок действия в днях (0 для бессрочного):",
"admin_promo_edit_success": "Промокод успешно обновлен.",
"admin_promo_invalid_input": "Неверный ввод, попробуйте еще раз.",
"admin_promo_edit_bonus_days": "🎁 Бонусные дни",
"admin_promo_edit_discount_percentage": "💰 Процент скидки",
"admin_promo_edit_max_activations": "🔢 Макс. активации",
"admin_promo_edit_validity": "⏰ Срок действия",
"admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
@@ -308,6 +317,7 @@
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_payment_received_traffic": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n🗂 Трафик: <b>{traffic_gb} ГБ</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
"log_promo_discount_activation": "💰 <b>Активирован промокод на скидку</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n💵 Скидка: <b>{discount_percentage}%</b>\n🕐 Время: {timestamp}",
"log_trial_activation": "🆓 <b>Активирован триал</b>\n\n👤 Пользователь: {user_display}\n⏰ Действует до: <b>{end_date}</b>\n🕐 Время: {timestamp}",
"log_panel_sync": "{status_emoji} <b>Синхронизация с панелью</b>\n\n📊 Статус: <b>{status}</b>\n👥 Обработано пользователей: <b>{users_processed}</b>\n📋 Синхронизировано подписок: <b>{subs_synced}</b>\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}",
"log_suspicious_promo": "⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n👤 Пользователь: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Ввод: <pre>{suspicious_input}</pre>\n🕐 Время: {timestamp}",
@@ -335,10 +345,17 @@
"admin_bulk_promo_invalid_quantity": "❌ Количество должно быть от 1 до 1000",
"admin_bulk_promo_enter_validity_days": "⏰ Введите количество дней действия промокодов (1-365):",
"admin_bulk_promo_creating": "⏳ Создаю {quantity} промокодов...",
"admin_promo_step0_type": "Выберите тип промокода:",
"admin_promo_type_bonus_days": "🎁 Бонусные дни (продление подписки)",
"admin_promo_type_discount": "💰 Скидка на покупку (%)",
"admin_promo_step1_code": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):",
"admin_promo_step2_bonus_days": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):",
"admin_promo_step2_discount_percentage": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Процент скидки\n\nКод: <b>{code}</b>\n\nВведите процент скидки для промокода <code>{code}</code> (от 1 до 100):",
"admin_promo_invalid_discount_percentage": "❌ Процент скидки должен быть от 1 до 100.",
"admin_promo_step3_max_activations": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
"admin_promo_step3_max_activations_discount": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nСкидка: <b>{discount_percentage}%</b>\n\nВведите максимальное количество активаций (1-10000):",
"admin_promo_step4_validity": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
"admin_promo_step4_validity_discount": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nСкидка: <b>{discount_percentage}%</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
"admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует",
"admin_promo_unlimited_validity": "♾️ Неограниченно",
"admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):",
@@ -373,7 +390,9 @@
"admin_promo_management_title": "🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:",
"admin_promo_management_empty": "📭 Промокоды отсутствуют",
"admin_promo_card_title": "🎟 <b>Промокод: {code}</b>",
"admin_promo_card_type": "📌 Тип: {type}",
"admin_promo_card_bonus_days": "🎁 Бонусные дни: <b>{days}</b>",
"admin_promo_card_discount_percentage": "💰 Скидка: <b>{percentage}%</b>",
"admin_promo_card_activations": "🔢 Активации: <b>{current}/{max}</b>",
"admin_promo_card_validity": "⏰ Действует до: <b>{validity}</b>",
"admin_promo_card_status": "📊 Статус: <b>{status}</b>",
@@ -486,5 +505,19 @@
"admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.",
"admin_ads_deleted_success": "Кампания удалена.",
"admin_ads_not_found": "Кампания не найдена.",
"admin_bulk_promo_creation_cancelled": "Массовое создание промокодов отменено.",
"admin_direct_empty_message": "Нельзя отправить пустое сообщение.",
"admin_logs_csv_export_failed": "❌ Ошибка экспорта CSV: {error}",
"admin_logs_csv_no_data": "Нет данных для экспорта.",
"admin_promo_creation_cancelled": "Создание промокода отменено.",
"admin_promo_invalid_number": "❌ Некорректное число. Введите число больше 0.",
"admin_sync_errors": "\n🚫 Ошибок синхронизации: {count}",
"error_generating_referral_link": "Ошибка генерации реферальной ссылки. Попробуйте позже.",
"error_occurred_processing_request": "Произошла ошибка при обработке запроса. Попробуйте снова.",
"inline_panel_stats_error": "❌ Не удалось получить статистику панели.",
"inline_system_error": "Ошибка загрузки статистики",
"no_bonus_placeholder": "нет",
"payment_successful_with_promo_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. продлена на {bonus_days} бонусных дней по промокоду и активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"system_or_unknown_user": "Система/неизвестный пользователь",
"free_kassa_order_full": "Заказ №{order_id} от {date}\n\n"
}
+1
View File
@@ -7,4 +7,5 @@ httpx>=0.27.0
pydantic_settings==2.12.0
sqlalchemy[asyncio]==2.0.45
asyncpg==0.31.0
alembic==1.18.3
aiocryptopay==0.4.8