Compare commits

...

91 Commits

Author SHA1 Message Date
Egor efa1b11db5 Merge pull request #2724 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.31.0
2026-03-12 08:30:35 +03:00
github-actions[bot] d0ce193edb chore(main): release 3.31.0 2026-03-12 05:30:07 +00:00
Egor 92d872236f Merge pull request #2723 from BEDOLAGA-DEV/dev
Dev
2026-03-12 08:29:34 +03:00
Egor a11f492801 Merge pull request #2722 from BEDOLAGA-DEV/main
ц
2026-03-12 08:25:26 +03:00
Fringg c8162505ed chore: apply ruff formatting to 4 files 2026-03-12 08:24:47 +03:00
Fringg 076290e0c1 feat: auto-sync squads to Remnawave when admin updates tariff
When admin changes allowed_squads or external_squad_uuid on a tariff,
automatically sync the new squad config to all active/trial subscriptions
in Remnawave panel via a background task (fire-and-forget).
2026-03-12 08:20:25 +03:00
Fringg bf72f241d8 fix: preserve purchased devices when admin changes user tariff
Previously subscription.device_limit was blindly overwritten with the new
tariff's base limit, losing any extra devices the user had purchased.
Now extra devices are calculated from the old tariff base and carried over,
capped at tariff.max_device_limit or global MAX_DEVICES_LIMIT.
2026-03-12 06:55:56 +03:00
Fringg 12ae871653 feat: referral links now point to web cabinet instead of bot
Centralized referral link generation into settings.get_referral_link().
When CABINET_URL is configured, links use {CABINET_URL}?ref={code}.
Falls back to Telegram bot deep link when CABINET_URL is not set.

- URL-encodes referral_code for safety
- Handles CABINET_URL with existing query params (uses & vs ?)
- Guards against None referral_code in all call sites
- QR code caching uses link hash for auto-invalidation
2026-03-12 06:45:17 +03:00
Fringg 8a362db783 fix: correct skipped_count in sync-squads circuit breaker and simplify ternary
- Add missing skipped_count increment when early-aborting due to circuit breaker
- Remove redundant `new_squads if new_squads else []` (already [] when empty)
- Add comment explaining asyncio safety of shared counter mutations
2026-03-12 06:28:13 +03:00
Fringg b1e2146254 feat: add sync-squads endpoint for bulk updating subscription squads in Remnawave
POST /admin/tariffs/{tariff_id}/sync-squads updates active_internal_squads
and external_squad_uuid for all active/trial subscriptions on a tariff.

- Concurrent API calls (semaphore=5) with circuit breaker (10 consecutive failures)
- Local DB updated only on successful API response to avoid split-brain
- Error messages sanitized (details in server logs only)
- Uses joinedload to avoid N+1 query for User.remnawave_uuid
2026-03-12 06:17:13 +03:00
Fringg 68bc8eb57c fix: update promo group via M2M table so admin changes persist
The admin endpoint wrote only to the legacy users.promo_group_id FK,
which got overwritten by sync_user_primary_promo_group on the next
transaction. Now writes to user_promo_groups M2M table (authoritative
source) and re-derives the FK via sync.

Also re-raise exceptions in _sync_user_primary_promo_group to prevent
committing inconsistent state between M2M and FK columns.
2026-03-12 06:03:26 +03:00
Fringg 9957259881 fix: add post_update=True to User.referrals self-referential relationship
Without post_update, SQLAlchemy's flush ordering cannot resolve the
circular dependency when both a user and their referrer are in the same
session. This caused referred_by_id to be silently NULLed during flush,
breaking Telegram login (which eagerly loads User.referrer) and causing
apparent admin rights loss in the cabinet.

OAuth login was unaffected because it uses a bare select(User) without
eager loading the referrer relationship.

Root cause confirmed by 6 parallel investigation agents tracing the
exact code paths through get_user_by_telegram_id → selectinload →
flush → circular dependency.
2026-03-12 05:39:29 +03:00
Fringg b3f3eba575 fix: prevent account takeover via auto_login_token, ensure promo group on all purchase paths
- Gate auto_login_token generation behind is_new_account flag in all 3 locations
  (fulfill_purchase PENDING/DELIVERED paths + activate_purchase) — prevents attacker
  from buying cheapest plan with victim's email to get their session token
- Assign default promo group in all _find_or_create_user paths including telegram
  IntegrityError fallbacks (7 return paths total)
- Create transaction records for landing purchases so promo group auto-assignment
  and contest tracking work correctly
- Add _resolve_payment_method helper for enum conversion with sub-option suffix stripping
2026-03-12 05:26:16 +03:00
Fringg a798f1143e refactor: remove estimated price from balance, simplify server sync, fix HTML injection
- Remove estimated renewal price display from balance top-up screen
- Remove country name generation during server/squad sync, use original RemnaWave name as display_name
- Add html.escape() for all display_name/country name values rendered in HTML-parsed Telegram messages
2026-03-12 04:58:49 +03:00
Fringg cb5126aff8 feat: add show_in_gift toggle for tariffs in admin panel
Add a per-tariff visibility flag (show_in_gift) that controls whether
a tariff appears in the /gift section. Enforced server-side in gift
config query, gift purchase endpoint, and landing page gift purchases.

Includes Alembic migration with idempotency guard and server_default.
2026-03-12 04:15:40 +03:00
Fringg 8b35428055 fix: reactivate subscription after traffic top-up when status is EXPIRED
When traffic is exhausted, RemnaWave may send user.expired webhook setting
local status to EXPIRED (not just DISABLED). reactivate_subscription() only
handled DISABLED→ACTIVE, silently ignoring EXPIRED subscriptions. After
purchasing additional GB, the subscription stayed expired and VPN remained
blocked despite payment.

Changes:
- reactivate_subscription() now handles both DISABLED and EXPIRED→ACTIVE
  when end_date is still in the future
- Inverted null end_date guard to block reactivation (defense-in-depth)
- Added enable_remnawave_user() call after update in all traffic/device
  top-up paths to ensure panel exits LIMITED state
- Gated enable call on subscription.status == 'active' to prevent
  enabling when reactivation was a no-op
- Fixed all 12 call sites across bot handlers, cabinet routes,
  miniapp, webapi, and auto-purchase service
2026-03-12 03:57:35 +03:00
Fringg 5424d8c314 fix: add Telegram Stars payment support for gift subscriptions
- Add telegram_stars handler in create_guest_payment() using
  bot.create_invoice_link() with guest_purchase_{token} payload
- Add guest_purchase_ prefix handling in Stars pre-checkout and
  successful_payment handlers with amount tolerance check (±5%)
- Pass Bot instance to PaymentService when payment method is Stars
- Add purchase_token format validation via regex guard
2026-03-12 03:32:08 +03:00
Egor df7411138e Merge pull request #2718 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.30.0
2026-03-11 03:56:12 +03:00
github-actions[bot] 4545bef7ea chore(main): release 3.30.0 2026-03-11 00:55:56 +00:00
Egor d7eb1e776a Merge pull request #2716 from BEDOLAGA-DEV/dev
Dev
2026-03-11 03:55:35 +03:00
Egor f8fc382143 Merge pull request #2717 from BEDOLAGA-DEV/main
w
2026-03-11 03:55:10 +03:00
Fringg e67b8e448e fix: reset subscription for paid users, trial-to-paid tariff conversion, gift purchase MissingGreenlet
- Remove is_active_paid_subscription guard from reset-subscription endpoint
- Add is_trial=False and status=ACTIVE on admin tariff change (guarded by
  tariff.is_trial_available and end_date check)
- Eagerly load user_promo_groups and promo_group in gift purchase locked query
- Move user_promo_groups access inside try/except in get_primary_promo_group()
2026-03-11 03:46:39 +03:00
Fringg bca8bab433 feat: add gifts section to admin user detail API
Add GET /{user_id}/gifts endpoint returning sent and received gift
subscriptions with COUNT queries for true totals, token truncation
for security, and noload() optimization for unused relationships.
2026-03-11 03:30:12 +03:00
Fringg 2fd0f6aa4e feat: add promo group and promo offer discounts to gift subscriptions
Apply promo group and active promo offer discounts to gift purchase flow.
Discounts stack multiplicatively with max(1, price) floor. FOR UPDATE
row lock prevents concurrent promo offer double-spend. Promo offers
consumed after purchase in both balance and gateway modes.
2026-03-11 02:58:34 +03:00
Fringg 864a4ed700 fix: record transactions for free tariff switches and admin tariff changes
- Add transaction records for free tariff switches (downgrade, upgrade_cost=0) in miniapp and cabinet
- Add atomic transaction records for admin tariff changes in bot handler and cabinet API
- Use commit=False for admin flows to ensure subscription change and transaction are committed together
2026-03-11 02:17:35 +03:00
Fringg 2879996455 fix: use keyword args for Path.mkdir in asyncio.to_thread
Positional args caused mode=True(1) and exist_ok=False(default),
raising FileExistsError when directories already existed.

Fixed 8 instances: 2 in log_rotation_service, 6 in backup_service.
2026-03-11 01:22:02 +03:00
Egor 4b4fced442 Merge pull request #2714 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.29.0
2026-03-10 23:01:35 +03:00
github-actions[bot] 8859e73890 chore(main): release 3.29.0 2026-03-10 20:01:13 +00:00
Egor d79866819c Merge pull request #2713 from BEDOLAGA-DEV/dev
Dev
2026-03-10 23:00:25 +03:00
Fringg 5a62f91ca2 chore: ruff format 4 files 2026-03-10 22:57:32 +03:00
Fringg def594bbb5 fix: panel sync now updates end_date in both directions
Previously, both webhook (user.modified) and batch sync paths
only updated end_date if the panel date was LATER than the local date.
This silently blocked any date reduction from the panel, causing
the bot to show stale expiry dates after admin changes in the panel.

Now the panel is treated as authoritative — end_date is synced
in both directions (forward and backward) for ACTIVE subscriptions.
2026-03-10 22:23:39 +03:00
Fringg 641ff86bf6 fix: stars rate rounding + device/traffic purchase stats
- Stars: ceil → round в rubles_to_stars, нормализация kopeks в cabinet invoice
- Устройства/трафик: добавлен transaction_type в subtract_user_balance,
  покупки устройств и трафика теперь создают SUBSCRIPTION_PAYMENT (не WITHDRAWAL),
  что исправляет отображение в статистике продаж
2026-03-10 21:50:20 +03:00
Fringg 2e59330e95 chore: resolve uv.lock merge conflict 2026-03-10 21:19:28 +03:00
Fringg 3c96c2affd fix: 3 bugs — notification type, referral with channel sub, BOT_USERNAME
1. Admin notification showed "renewal" instead of "first purchase" for new
   users because has_had_paid_subscription was set before notification.
   All 21 call sites now pass explicit purchase_type.

2. Partner referral not counted when mandatory channel subscription enabled.
   required_sub_channel_check saved campaign_id but not referrer_id from
   campaign.partner_user_id. Also removed duplicate DB query.

3. BOT_USERNAME auto-detection moved before web server start to close
   race window on /cabinet/branding/telegram-widget endpoint.
2026-03-10 21:19:18 +03:00
c0mrade 541f64d5bc Merge pull request #2711 from FireWookie/dev_nikita
FIX PR Problems
2026-03-10 14:17:12 +03:00
firewookie e82a1ccf6d FIX PR Problems 2026-03-10 16:16:18 +05:00
c0mrade 015be30a27 Merge pull request #2710 from FireWookie/feature/riopay
FIX PR Problems
2026-03-10 14:13:25 +03:00
firewookie 6817b9e256 FIX PR Problems 2026-03-10 16:11:28 +05:00
c0mrade 59248011c2 Merge pull request #2678 from FireWookie/dev_nikita
Add: Рекуренты Юкасса + fix: скрытие нулевых бонусов в реферальной программе
2026-03-10 13:06:35 +03:00
firewookie 39d007ff3e FIX PR Problems 2026-03-10 15:01:28 +05:00
firewookie 94b211e2a7 FIX PR Problems 2026-03-10 15:00:41 +05:00
firewookie c84dbf82fc update 2026-03-10 14:50:26 +05:00
FireWookie 9ad684c8c9 Merge pull request #7 from FireWookie/dev
Dev
2026-03-10 14:42:53 +05:00
FireWookie d147be0316 Merge pull request #6 from FireWookie/dev
Dev
2026-03-10 14:42:05 +05:00
c0mrade 9281523e96 Merge pull request #2690 from FireWookie/feature/riopay
RioPay
2026-03-10 12:41:53 +03:00
firewookie fd3466b75c fix PR problems 2026-03-10 14:29:29 +05:00
FireWookie dcfd54a7cb Merge pull request #5 from FireWookie/dev
Dev
2026-03-10 14:18:49 +05:00
FireWookie 5c2e5dfaab Merge branch 'BEDOLAGA-DEV:main' into feature/riopay 2026-03-10 14:14:23 +05:00
firewookie df112f3659 fix PR problems 2026-03-10 14:08:10 +05:00
Fringg a90d2d9367 fix: 3 critical issues from second-round review
1. Balance-mode gift purchase leaked full 64-char token in response.
   Gateway path truncated to [:12] but balance path didn't. Fixed.

2. retry_stuck_pending_activation referenced GuestPurchase.updated_at
   which doesn't exist on the model. Changed to paid_at (mirrors
   retry_stuck_paid_purchases pattern).

3. clear_notifications() called db.commit() unconditionally, defeating
   commit=False in replace_subscription. Added commit parameter with
   default=True for backward compat, passed through from caller.
2026-03-10 07:09:39 +03:00
Fringg 5c34656476 fix: address review findings from 6-agent audit
1. Truncate tokens to 12 chars in all API responses (SentGift,
   PendingGift, ReceivedGift, PurchaseStatus, PurchaseResponse,
   return URL) — full token no longer leaves the server
2. Status endpoint supports prefix-based token lookup
3. create_paid_subscription/replace_subscription accept commit=False
   — activate_purchase now uses single atomic commit for subscription
   + purchase status update (fixes double-commit gap)
4. Bot handler uses flush() instead of commit() before svc_activate
   — consistent with cabinet endpoint, allows rollback on failure
5. Add retry_stuck_pending_activation() for purchases stuck in
   PENDING_ACTIVATION status (10 min threshold)
6. Add varchar_pattern_ops index for prefix queries on token column
2026-03-10 07:02:14 +03:00
Fringg 8a8337f538 fix: add minimum 8-char length check for gift token in bot deep link 2026-03-10 06:56:08 +03:00
Fringg 42b6c80a48 refactor: rename GIFTCODE_ start parameter prefix to GIFT_ 2026-03-10 06:48:49 +03:00
Fringg b30c73c300 feat: prevent self-activation of gift codes
Buyer cannot activate their own gift, both via cabinet API
(returns 400 "Cannot activate your own gift") and bot deep link
(silently skips activation).
2026-03-10 06:44:58 +03:00
Fringg 363ccce56d fix: refresh user subscription after gift activation in /start
After svc_activate creates a subscription, the user object still
has stale cached data. Refresh the subscription attribute so the
main menu immediately shows the active subscription status.
2026-03-10 06:36:33 +03:00
Fringg 0005d59da1 fix: remove begin_nested that breaks activate_purchase transaction
activate_purchase -> create_paid_subscription calls db.commit()
internally, which closes the savepoint context and causes
InvalidRequestError on subsequent db.refresh(). Replace savepoint
with a plain commit before calling svc_activate.
2026-03-10 06:33:31 +03:00
Fringg 38c6adfdb4 fix: pass full token to svc_activate instead of truncated prefix
Telegram truncates start parameters to 64 chars, so gift_token from
deep link may be a prefix. svc_activate does exact match internally,
so we must pass gift_purchase.token (full token from DB) instead.
2026-03-10 06:23:55 +03:00
Fringg 4fb72ae6e3 fix: support prefix-based gift code lookup for activation
Displayed gift codes (GIFT-XXXXXXXXXXXX) are 12-char prefixes of the
full 64-char token. Activation now accepts prefix match (min 8 chars)
so both the short display code and full token work. Also fixes Telegram
deep link truncation (64-char limit cuts the token).
2026-03-10 06:20:07 +03:00
Fringg 05bcac502e fix: code-only gifts skip fulfillment in gateway webhook + retry service
- Gateway webhook: skip fulfill_purchase() for code-only gifts (is_gift=True, no recipient)
- Retry service: exclude code-only gifts from stuck PAID retry query
- Status endpoint: return is_code_only and purchase_token for code-only gifts
2026-03-10 06:03:26 +03:00
Fringg 769d3a0b30 refactor: deduplicate gift activation in start.py
Replace inline gift activation block (30+ lines) with a call to
_activate_pending_gift_after_registration() helper. Eliminates
code duplication between existing-user and new-user activation paths.
2026-03-10 05:41:05 +03:00
Fringg 5ffce175dc feat: gift subscription code-only purchase + activation via deep link
- Add code-only gift purchase (no recipient required)
- Gift activate endpoint: accept PAID + PENDING_ACTIVATION statuses
- Bot deep link: /start GIFTCODE_{token} auto-activation for new and existing users
- Add _activate_pending_gift_after_registration() helper with savepoint isolation
- Security: FOR UPDATE on activation queries to prevent race conditions
- Security: rate limiting on activate, ownership check before status leak
- Security: uniform 404 responses to prevent token enumeration
- Add selectinload for tariff/user/buyer relationships in all gift queries
- Add .limit(100) to pending gifts query
- Make recipient_type/recipient_value optional in GiftPurchaseRequest schema
2026-03-10 05:37:41 +03:00
Egor 1a2f0fcbe8 Merge pull request #2709 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.28.1
2026-03-10 03:39:48 +03:00
github-actions[bot] fd1e728396 chore(main): release 3.28.1 2026-03-10 00:39:15 +00:00
Egor ec41d65501 Merge pull request #2708 from BEDOLAGA-DEV/dev
Dev
2026-03-10 03:38:54 +03:00
Egor 5212877801 Merge pull request #2707 from BEDOLAGA-DEV/main
w
2026-03-10 03:37:02 +03:00
Fringg bc9003c336 chore: ruff format 2026-03-10 03:36:32 +03:00
Fringg fcdeff1ee5 fix: migrate pricing to days-based proration, fix promo revenue leaks, fix admin panel bugs
- Migrate all addon pricing (devices, traffic, countries) from months-based to days-based proration
- Remove get_remaining_months() utility, use days_left / 30 consistently
- Fix promo/campaign balance bonuses counted as revenue in reports (add REAL_PAYMENT_METHODS filter)
- Fix partner stats, campaign stats, miniapp stats, referral fraud detection promo deposit leaks
- Fix admin balance history showing deductions with + sign (use -abs for expense types)
- Fix promo offer deactivation returning 400 for non-promocode offers
- Fix daily tariff renewal requesting 30-day renewal instead of 1-day purchase
2026-03-10 03:31:07 +03:00
firewookie c7bebae14a back docker 2026-03-09 14:11:50 +05:00
firewookie 8ee287f8cd remove locales from git 2026-03-09 14:11:34 +05:00
firewookie 6f99b83c61 remove locales from git 2026-03-09 14:09:16 +05:00
firewookie 1a3c6fafa3 update saved payment method 2026-03-09 14:07:42 +05:00
firewookie be2ec091a6 Правки по замечаниям 2026-03-09 14:06:00 +05:00
firewookie d4dc0b76ba fix linter 2026-03-09 13:38:46 +05:00
firewookie 2dfd0e6452 Правки по замечаниям 2026-03-09 13:34:25 +05:00
firewookie 8e53b81b3d fix recurrent linter 2026-03-08 09:36:47 +05:00
firewookie 69ca37bc6e fix project 2026-03-08 09:35:10 +05:00
FireWookie 26daf9f6c8 Merge pull request #4 from FireWookie/main
Merge as main project
2026-03-08 09:32:37 +05:00
firewookie 34aae0dd26 fix formatting 2026-03-08 09:30:47 +05:00
firewookie 0551a6e23c fix migrations 2026-03-08 09:28:32 +05:00
firewookie 92cc602892 Merge remote-tracking branch 'origin/feature/riopay' into dev 2026-03-08 09:26:32 +05:00
firewookie 555b887952 Merge remote-tracking branch 'origin/dev' into dev 2026-03-08 09:23:08 +05:00
firewookie 848c9f71a2 reviewers fix 2026-03-08 09:22:44 +05:00
FireWookie 4477e03d83 Merge pull request #3 from FireWookie/main
merge as main project
2026-03-08 09:20:33 +05:00
firewookie a6849242ff add riopay 2026-03-07 15:35:26 +05:00
firewookie 861ffe5424 fix migration 2026-03-06 12:36:32 +05:00
firewookie 06ccf4b275 webhook install bugfix + add info in readme 2026-03-06 11:56:15 +05:00
firewookie 7ed91b13eb - RioPay payment system integration 2026-03-06 11:44:22 +05:00
firewookie 319f49435a - Карточки "Бонус новому пользователю" и "Бонус пригласившему"
скрыты когда значение = 0
- Динамический grid-cols в зависимости от количества видимых карточек
- Добавлено поле max_commission_payments в тип ReferralTerms
- Уведомления бота: строки с бонусом нового пользователя и бонусом
    пригласившего скрываются если соответствующие настройки = 0
- Раздел "Как работают награды": карточки бонусов скрыты при значении 0
- Invite message: строка про бонус за первое пополнение скрыта при 0
- Текст комиссии: "с каждого пополнения" при без лимита,
    "с пополнений" при наличии REFERRAL_MAX_COMMISSION_PAYMENTS- API /terms: добавлено поле max_commission_payments
- Добавлен ключ локали REFERRAL_REWARD_COMMISSION_LIMITED (ru/en/ua/zh/fa)
2026-03-06 11:08:52 +05:00
firewookie 23761a74f2 fix formatting 2026-03-06 09:57:24 +05:00
firewookie 8620aaedb1 fix formatting 2026-03-06 09:54:55 +05:00
firewookie aaffc26a90 - Интеграция рекурентов от Юкассы
- Багфикс личного кабинета
2026-03-06 09:47:58 +05:00
124 changed files with 5037 additions and 1071 deletions
+21
View File
@@ -369,6 +369,8 @@ REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
REFERRAL_COMMISSION_PERCENT=25
# Макс. кол-во платежей реферала, с которых начисляется комиссия (0 = без лимита)
REFERRAL_MAX_COMMISSION_PAYMENTS=0
# Показывать раздел партнёрки в кабинете
REFERRAL_PARTNER_SECTION_VISIBLE=true
@@ -492,6 +494,11 @@ YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
# Быстрый выбор суммы пополнения через YooKassa
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Рекуррентные платежи YooKassa (автосохранение карты для автоплатежей)
YOOKASSA_RECURRENT_ENABLED=false
# true = карта сохраняется обязательно, false = пользователь решает (чекбокс на стороне YooKassa)
YOOKASSA_RECURRENT_REQUIRED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
# Отключить пополнение баланса через поддержку
@@ -655,6 +662,20 @@ KASSA_AI_WEBHOOK_PORT=8089
# Способ оплаты: 44 = СБП (QR), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID=44
# ===== RIOPAY (api.riopay.online) =====
RIOPAY_ENABLED=false
RIOPAY_API_TOKEN=
# Ключ для HMAC-SHA512 верификации вебхуков (если не указан, используется RIOPAY_API_TOKEN)
RIOPAY_WEBHOOK_SECRET=
RIOPAY_DISPLAY_NAME=RioPay
RIOPAY_CURRENCY=RUB
RIOPAY_MIN_AMOUNT_KOPEKS=10000
RIOPAY_MAX_AMOUNT_KOPEKS=100000000
RIOPAY_WEBHOOK_PATH=/riopay-webhook
# URL для редиректа после оплаты (опционально)
RIOPAY_SUCCESS_URL=
RIOPAY_FAIL_URL=
# ===== WATA =====
WATA_ENABLED=false
WATA_BASE_URL=https://api.wata.pro
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.28.0"
".": "3.31.0"
}
+77
View File
@@ -1,5 +1,82 @@
# Changelog
## [3.31.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.30.0...v3.31.0) (2026-03-12)
### New Features
* add show_in_gift toggle for tariffs in admin panel ([cb5126a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb5126aff8c15938a59ea9c4f8e605b250b05dbc))
* add sync-squads endpoint for bulk updating subscription squads in Remnawave ([b1e2146](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b1e2146254255586b5be9bd894ac4d113a0a8cf5))
* auto-sync squads to Remnawave when admin updates tariff ([076290e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/076290e0c1d81b610a7653d6b64ed218e0f124b4))
* referral links now point to web cabinet instead of bot ([12ae871](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/12ae871653399bc4ccd23b6394878e814ce9cd75))
### Bug Fixes
* add post_update=True to User.referrals self-referential relationship ([9957259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/995725988150f31d193631120a4692e88fa4dd57))
* add Telegram Stars payment support for gift subscriptions ([5424d8c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5424d8c31484873b0adc0bc980abdc51ee81325b))
* correct skipped_count in sync-squads circuit breaker and simplify ternary ([8a362db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a362db7833b5b7793b5b52345d227cb84cbc39e))
* preserve purchased devices when admin changes user tariff ([bf72f24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf72f241d81e4432f50a61ec3bb829d18c92955d))
* prevent account takeover via auto_login_token, ensure promo group on all purchase paths ([b3f3eba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b3f3eba5756404df9ed0f12d8048244ca536f7d3))
* reactivate subscription after traffic top-up when status is EXPIRED ([8b35428](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b354280558a5f28d1b99eae55ccd21a4af6a07b))
* update promo group via M2M table so admin changes persist ([68bc8eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68bc8eb57c792059d2be8a8fff6bba3254d3773d))
### Refactoring
* remove estimated price from balance, simplify server sync, fix HTML injection ([a798f11](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a798f1143eebf52e18254bddd610f7f14a0c4056))
## [3.30.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.29.0...v3.30.0) (2026-03-11)
### New Features
* add gifts section to admin user detail API ([bca8bab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bca8bab4336b2583da9be8c642985e6a0151e33d))
* add promo group and promo offer discounts to gift subscriptions ([2fd0f6a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2fd0f6aa4eb62f704208c1e56a6542d3967e7867))
### Bug Fixes
* record transactions for free tariff switches and admin tariff changes ([864a4ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/864a4ed7005195ff3be3a8bb2e7666bc5a7f3e4e))
* reset subscription for paid users, trial-to-paid tariff conversion, gift purchase MissingGreenlet ([e67b8e4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e67b8e448e5396ee6daa8c6278bb5a0b313dda74))
* use keyword args for Path.mkdir in asyncio.to_thread ([2879996](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/287999645506a49b6693a184757598e1cdceb4d8))
## [3.29.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.28.1...v3.29.0) (2026-03-10)
### New Features
* gift subscription code-only purchase + activation via deep link ([5ffce17](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ffce175dcb8aebf22cf536bfa032c66da284600))
* prevent self-activation of gift codes ([b30c73c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b30c73c300019646ea4a0d7e1bf758464ee58f0f))
### Bug Fixes
* 3 bugs — notification type, referral with channel sub, BOT_USERNAME ([3c96c2a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3c96c2affd5a803311e3c0c9a0f844d2217f387a))
* 3 critical issues from second-round review ([a90d2d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a90d2d936793daaadf116cf314b736a9ebfb7c3b))
* add minimum 8-char length check for gift token in bot deep link ([8a8337f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a8337f538c3fcaf84b84c34b7a1e38a4ce9d580))
* address review findings from 6-agent audit ([5c34656](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5c3465647639d6f07432bc3d725bba9396af6c45))
* code-only gifts skip fulfillment in gateway webhook + retry service ([05bcac5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/05bcac502efb1b4298a1c6be91bba5d7c057b9f0))
* panel sync now updates end_date in both directions ([def594b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/def594bbb55ef45d3df81524bd8841de73a07340))
* pass full token to svc_activate instead of truncated prefix ([38c6adf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38c6adfdb4d4fc786bf6ca34a5d54025126130c0))
* refresh user subscription after gift activation in /start ([363ccce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/363ccce56d3e61554ca49d725322a79b05bc65d3))
* remove begin_nested that breaks activate_purchase transaction ([0005d59](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0005d59da1e58c38561c8346db89d8475a25d7df))
* stars rate rounding + device/traffic purchase stats ([641ff86](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/641ff86bf6f1ac1f22146f4344beda05759869fc))
* support prefix-based gift code lookup for activation ([4fb72ae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4fb72ae6e3bc65d93ab84b594b4ff5b4856c5357))
### Refactoring
* deduplicate gift activation in start.py ([769d3a0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/769d3a0b309fb6be1c3175cd66a0df7cb6e2fb67))
* rename GIFTCODE_ start parameter prefix to GIFT_ ([42b6c80](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/42b6c80a48ad0100d5ddbbe99a096ebb7b292f08))
## [3.28.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.28.0...v3.28.1) (2026-03-10)
### Bug Fixes
* migrate pricing to days-based proration, fix promo revenue leaks, fix admin panel bugs ([fcdeff1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcdeff1ee5155c88c634e12a703159c221d66af5))
## [3.28.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.27.0...v3.28.0) (2026-03-09)
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.28.0" # x-release-please-version
ARG VERSION="v3.31.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+23
View File
@@ -610,6 +610,16 @@ hooks.domain.com {
}
}
handle /riopay-webhook {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
handle /remnawave-webhook {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
@@ -827,6 +837,18 @@ http {
proxy_buffering off;
proxy_request_buffering off;
}
location = /riopay-webhook {
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_request_buffering off;
}
location = /remnawave-webhook {
proxy_pass http://remnawave_bot_unified;
@@ -1455,6 +1477,7 @@ CONTEST_BUTTON_VISIBLE=true
- 💳 **WATA**
- 💳 **Freekassa** (NSPK СБП + карты)
- 💳 **CloudPayments** (карты + СБП)
- 💳 **RioPay** (карты + СБП)
- 🔥 Автогенерация счетов и webhook-уведомления
- 💼 История операций
- 🔄 Автоплатёж с настройкой дня списания
+4 -3
View File
@@ -85,6 +85,7 @@ async def update_partner_settings(
admin: User = Depends(require_permission('partners:settings')),
):
"""Update partner system settings."""
import asyncio
from pathlib import Path
# Update in-memory settings
@@ -104,8 +105,8 @@ async def update_partner_settings(
# Persist to .env file
try:
env_file = Path('.env')
if env_file.exists():
lines = env_file.read_text().splitlines()
if await asyncio.to_thread(env_file.exists):
lines = (await asyncio.to_thread(env_file.read_text)).splitlines()
updates: dict[str, str] = {}
if request.withdrawal_enabled is not None:
@@ -143,7 +144,7 @@ async def update_partner_settings(
if key not in updated_keys:
new_lines.append(f'{key}={value}')
env_file.write_text('\n'.join(new_lines) + '\n')
await asyncio.to_thread(env_file.write_text, '\n'.join(new_lines) + '\n')
logger.info('Updated partner settings in .env file', admin_id=admin.id)
except Exception as e:
logger.warning('Failed to update .env file', error=e)
+1 -1
View File
@@ -210,7 +210,7 @@ def _is_checkable(record: PendingPayment) -> bool:
if record.method == PaymentMethod.YOOKASSA:
return status_str in {'pending', 'waiting_for_capture'}
if record.method == PaymentMethod.CRYPTOBOT:
return status_str in {'active'}
return status_str == 'active'
if record.method == PaymentMethod.CLOUDPAYMENTS:
return status_str in {'pending', 'authorized'}
if record.method == PaymentMethod.FREEKASSA:
+44 -25
View File
@@ -489,44 +489,63 @@ async def admin_deactivate_discount_promocode(
admin: User = Depends(require_permission('promocodes:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> DeactivateDiscountResponse:
"""Admin: deactivate a user's active discount promo code."""
"""Admin: deactivate a user's active discount (promo code or promo offer)."""
from app.database.crud.user import get_user_by_id as get_user
target_user = await get_user(db, user_id)
if not target_user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
from app.services.promocode_service import PromoCodeService
current_discount = getattr(target_user, 'promo_offer_discount_percent', 0) or 0
source = getattr(target_user, 'promo_offer_discount_source', None)
service = PromoCodeService()
result = await service.deactivate_discount_promocode(
db=db,
user_id=user_id,
admin_initiated=True,
)
if current_discount <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'User has no active discount')
if result['success']:
return DeactivateDiscountResponse(
success=True,
message=f'Discount promo code deactivated for user {user_id}',
deactivated_code=result.get('deactivated_code'),
discount_percent=result.get('discount_percent', 0),
# If source is a promo code, use the service to properly rollback usage
if source and source.startswith('promocode:'):
from app.services.promocode_service import PromoCodeService
service = PromoCodeService()
result = await service.deactivate_discount_promocode(
db=db,
user_id=user_id,
admin_initiated=True,
)
error_messages = {
'user_not_found': 'User not found',
'no_active_discount_promocode': 'User has no active discount from a promo code',
'discount_already_expired': 'Discount has already expired (cleaned up)',
'server_error': 'Server error occurred',
}
if result['success']:
return DeactivateDiscountResponse(
success=True,
message=f'Discount promo code deactivated for user {user_id}',
deactivated_code=result.get('deactivated_code'),
discount_percent=result.get('discount_percent', 0),
user_id=user_id,
)
error_code = result.get('error', 'server_error')
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
error_messages = {
'user_not_found': 'User not found',
'no_active_discount_promocode': 'User has no active discount from a promo code',
'discount_already_expired': 'Discount has already expired (cleaned up)',
'server_error': 'Server error occurred',
}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message,
error_code = result.get('error', 'server_error')
raise HTTPException(status.HTTP_400_BAD_REQUEST, error_messages.get(error_code, 'Failed to deactivate'))
# For non-promocode offers (admin offers, etc.) — just clear the fields
old_percent = target_user.promo_offer_discount_percent
target_user.promo_offer_discount_percent = 0
target_user.promo_offer_discount_source = None
target_user.promo_offer_discount_expires_at = None
target_user.updated_at = datetime.now(UTC)
await db.commit()
return DeactivateDiscountResponse(
success=True,
message=f'Promo offer deactivated for user {user_id}',
deactivated_code=None,
discount_percent=old_percent,
user_id=user_id,
)
+3 -1
View File
@@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.campaign import get_campaign_statistics, get_campaigns_count, get_campaigns_list
from app.database.crud.server_squad import get_server_statistics
from app.database.crud.subscription import get_subscriptions_statistics
from app.database.crud.transaction import get_revenue_by_period, get_transactions_statistics
from app.database.crud.transaction import REAL_PAYMENT_METHODS, get_revenue_by_period, get_transactions_statistics
from app.database.models import (
ReferralEarning,
Subscription,
@@ -931,6 +931,7 @@ async def get_recent_payments(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= today_start,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
)
)
)
@@ -942,6 +943,7 @@ async def get_recent_payments(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= week_ago,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
)
)
)
+245 -3
View File
@@ -1,9 +1,12 @@
"""Admin routes for managing tariffs in cabinet."""
import asyncio
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.tariff import (
@@ -17,7 +20,7 @@ from app.database.crud.tariff import (
set_tariff_promo_groups,
update_tariff,
)
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Tariff, Transaction, TransactionType, User
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.tariffs import (
@@ -26,6 +29,7 @@ from ..schemas.tariffs import (
PromoGroupInfo,
ServerInfo,
ServerTrafficLimit,
SyncSquadsResponse,
TariffCreateRequest,
TariffDetailResponse,
TariffListItem,
@@ -127,6 +131,7 @@ async def list_tariffs(
is_daily=tariff.is_daily,
daily_price_kopeks=tariff.daily_price_kopeks,
allow_traffic_topup=tariff.allow_traffic_topup,
show_in_gift=tariff.show_in_gift,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
tier_level=tariff.tier_level,
@@ -265,6 +270,8 @@ async def get_tariff(
traffic_reset_mode=tariff.traffic_reset_mode,
# Внешний сквад
external_squad_uuid=tariff.external_squad_uuid,
# Показывать в подарках
show_in_gift=tariff.show_in_gift,
created_at=tariff.created_at,
updated_at=tariff.updated_at,
)
@@ -303,7 +310,7 @@ async def create_new_tariff(
period_prices=period_prices_dict,
allowed_squads=request.allowed_squads,
server_traffic_limits=server_limits_dict,
promo_group_ids=request.promo_group_ids if request.promo_group_ids else None,
promo_group_ids=request.promo_group_ids or None,
# Произвольное количество дней
custom_days_enabled=request.custom_days_enabled,
price_per_day_kopeks=request.price_per_day_kopeks,
@@ -321,6 +328,8 @@ async def create_new_tariff(
traffic_reset_mode=request.traffic_reset_mode,
# Внешний сквад
external_squad_uuid=request.external_squad_uuid,
# Показывать в подарках
show_in_gift=request.show_in_gift,
)
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
@@ -347,6 +356,10 @@ async def update_existing_tariff(
detail='Tariff not found',
)
# Capture old values for change detection
old_squads = list(tariff.allowed_squads) if tariff.allowed_squads else []
old_external_squad = tariff.external_squad_uuid
# Build updates dict
updates = {}
if request.name is not None:
@@ -413,6 +426,9 @@ async def update_existing_tariff(
# Внешний сквад (None допускается для сброса)
if 'external_squad_uuid' in request.model_fields_set:
updates['external_squad_uuid'] = request.external_squad_uuid
# Показывать в подарках
if request.show_in_gift is not None:
updates['show_in_gift'] = request.show_in_gift
if updates:
await update_tariff(db, tariff, **updates)
@@ -426,6 +442,18 @@ async def update_existing_tariff(
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
# Auto-sync squads to active subscriptions in Remnawave when squads changed
new_squads = tariff.allowed_squads or []
squads_changed = request.allowed_squads is not None and sorted(old_squads) != sorted(new_squads)
ext_squad_changed = (
'external_squad_uuid' in request.model_fields_set and tariff.external_squad_uuid != old_external_squad
)
if squads_changed or ext_squad_changed:
asyncio.create_task(
_background_sync_squads(tariff_id, admin.id),
name=f'sync-squads-tariff-{tariff_id}',
)
return await get_tariff(tariff_id, admin, db)
@@ -586,3 +614,217 @@ async def get_tariff_stats(
revenue_kopeks=revenue_kopeks,
revenue_rubles=revenue_kopeks / 100,
)
async def _background_sync_squads(tariff_id: int, admin_id: int) -> None:
"""Run squad sync in background with its own DB session (fire-and-forget)."""
from app.database.database import AsyncSessionLocal
from app.services.remnawave_service import RemnaWaveService
try:
async with AsyncSessionLocal() as db:
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
return
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(joinedload(Subscription.user))
.where(
and_(
Subscription.tariff_id == tariff_id,
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
User.remnawave_uuid.isnot(None),
)
)
)
subscriptions = list(result.unique().scalars().all())
if not subscriptions:
return
new_squads = tariff.allowed_squads or []
ext_squad_uuid = tariff.external_squad_uuid
service = RemnaWaveService()
updated = 0
failed = 0
async with service.get_api_client() as api:
semaphore = asyncio.Semaphore(5)
async def _sync_one(sub: Subscription) -> None:
nonlocal updated, failed
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
if not remnawave_uuid:
return
async with semaphore:
try:
await api.update_user(
uuid=remnawave_uuid,
active_internal_squads=new_squads,
external_squad_uuid=ext_squad_uuid,
)
sub.connected_squads = new_squads
updated += 1
except Exception as e:
failed += 1
logger.warning(
'Background sync: failed to sync squads for user',
user_id=sub.user_id,
error=str(e),
)
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
await db.commit()
logger.info(
'Background squad sync completed after tariff update',
admin_id=admin_id,
tariff_id=tariff_id,
tariff_name=tariff.name,
total=len(subscriptions),
updated=updated,
failed=failed,
)
except Exception:
logger.exception('Background squad sync failed', tariff_id=tariff_id)
_SYNC_SQUADS_CONCURRENCY = 5
_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES = 10
@router.post('/{tariff_id}/sync-squads', response_model=SyncSquadsResponse)
async def sync_tariff_squads(
tariff_id: int,
admin: User = Depends(require_permission('tariffs:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Sync squads from tariff to all active/trial subscriptions in Remnawave panel.
Updates connected_squads and external_squad_uuid for every active or trial
subscription linked to this tariff. Only users that have a remnawave_uuid
(i.e. already exist in the panel) are touched.
"""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
# Fetch active + trial subscriptions for this tariff whose users exist in Remnawave
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(joinedload(Subscription.user))
.where(
and_(
Subscription.tariff_id == tariff_id,
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
User.remnawave_uuid.isnot(None),
)
)
)
subscriptions = list(result.unique().scalars().all())
if not subscriptions:
return SyncSquadsResponse(
tariff_id=tariff_id,
tariff_name=tariff.name,
total_subscriptions=0,
updated_count=0,
failed_count=0,
skipped_count=0,
)
new_squads = tariff.allowed_squads or []
# None means "clear external squad" — intentional when tariff has none
ext_squad_uuid = tariff.external_squad_uuid
# Sync to Remnawave panel with concurrency limit and circuit breaker
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
updated_count = 0
failed_count = 0
skipped_count = 0
consecutive_failures = 0
errors: list[str] = []
aborted = False
async with service.get_api_client() as api:
semaphore = asyncio.Semaphore(_SYNC_SQUADS_CONCURRENCY)
async def _sync_one(sub: Subscription) -> str:
# Counter mutations are safe: no `await` between read-modify-write
# and the check within each branch (single-threaded asyncio event loop).
nonlocal updated_count, failed_count, skipped_count, consecutive_failures, aborted
if aborted:
skipped_count += 1
return 'skipped'
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
if not remnawave_uuid:
skipped_count += 1
return 'skipped'
async with semaphore:
if aborted:
skipped_count += 1
return 'skipped'
try:
await api.update_user(
uuid=remnawave_uuid,
active_internal_squads=new_squads,
external_squad_uuid=ext_squad_uuid,
)
# Update local DB only on successful API call
sub.connected_squads = new_squads
updated_count += 1
consecutive_failures = 0
return 'ok'
except Exception as e:
failed_count += 1
consecutive_failures += 1
errors.append(f'user_id={sub.user_id}: sync failed')
logger.warning(
'Failed to sync squads for user in Remnawave',
user_id=sub.user_id,
remnawave_uuid=remnawave_uuid,
error=str(e),
)
if consecutive_failures >= _SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES:
aborted = True
errors.append(f'Aborted after {_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES} consecutive failures')
return 'error'
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
# Commit local DB changes only for successfully synced subscriptions
await db.commit()
logger.info(
'Admin synced squads for tariff',
admin_id=admin.id,
tariff_id=tariff_id,
tariff_name=tariff.name,
total=len(subscriptions),
updated=updated_count,
failed=failed_count,
skipped=skipped_count,
)
return SyncSquadsResponse(
tariff_id=tariff_id,
tariff_name=tariff.name,
total_subscriptions=len(subscriptions),
updated_count=updated_count,
failed_count=failed_count,
skipped_count=skipped_count,
errors=errors[:20],
)
+4 -3
View File
@@ -246,6 +246,7 @@ async def update_ticket_settings(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket system settings."""
import asyncio
from pathlib import Path
from app.services.support_settings_service import SupportSettingsService
@@ -280,8 +281,8 @@ async def update_ticket_settings(
# Try to persist to .env file
try:
env_file = Path('.env')
if env_file.exists():
lines = env_file.read_text().splitlines()
if await asyncio.to_thread(env_file.exists):
lines = (await asyncio.to_thread(env_file.read_text)).splitlines()
updates = {}
if request.sla_enabled is not None:
@@ -314,7 +315,7 @@ async def update_ticket_settings(
if key not in updated_keys:
new_lines.append(f'{key}={value}')
env_file.write_text('\n'.join(new_lines) + '\n')
await asyncio.to_thread(env_file.write_text, '\n'.join(new_lines) + '\n')
logger.info('Updated ticket settings in .env file')
except Exception as e:
logger.warning('Failed to update .env file', error=e)
+230 -30
View File
@@ -4,8 +4,9 @@ from datetime import UTC, datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Integer, and_, func, or_, select
from sqlalchemy import Integer, and_, delete as sa_delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.crud.campaign import get_campaign_registration_by_user
from app.database.crud.subscription import (
@@ -24,7 +25,9 @@ from app.database.crud.user import (
get_users_statistics,
subtract_user_balance,
)
from app.database.crud.user_promo_group import sync_user_primary_promo_group
from app.database.models import (
GuestPurchase,
PromoGroup,
ReferralEarning,
Subscription,
@@ -34,12 +37,15 @@ from app.database.models import (
Transaction,
TransactionType,
User,
UserPromoGroup,
UserStatus,
)
from app.utils.timezone import panel_datetime_to_utc
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.users import (
AdminUserGiftItem,
AdminUserGiftsResponse,
DeleteDeviceResponse,
DeleteUserRequest,
DeleteUserResponse,
@@ -610,14 +616,18 @@ async def get_user_detail(
transactions_result = await db.execute(transactions_q)
transactions = transactions_result.scalars().all()
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
_EXPENSE_TYPES = {
TransactionType.WITHDRAWAL.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
TransactionType.GIFT_PAYMENT.value,
}
recent_transactions = [
UserTransactionItem(
id=t.id,
type=t.type,
amount_kopeks=abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
amount_kopeks=-abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=-abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
description=t.description,
payment_method=t.payment_method,
is_completed=t.is_completed,
@@ -1018,10 +1028,10 @@ async def update_user_subscription(
)
if request.action == 'extend':
if not request.days:
if not request.days or request.days <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Days parameter is required for extend action',
detail='Days must be a positive integer',
)
await extend_subscription(db, subscription, request.days)
@@ -1040,6 +1050,36 @@ async def update_user_subscription(
subscription=await _build_subscription_info_async(db, subscription),
)
if request.action == 'shorten':
if not request.days or request.days <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Days must be a positive integer',
)
# Сокращение через отрицательный аргумент: extend_subscription(-N) уменьшает end_date
await extend_subscription(db, subscription, -request.days)
await db.refresh(subscription)
# Check if subscription expired after shortening
if subscription.end_date <= datetime.now(UTC):
subscription.status = SubscriptionStatus.EXPIRED.value
await db.commit()
await db.refresh(subscription)
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(
'Admin shortened subscription for user by days', admin_id=admin.id, user_id=user_id, days=request.days
)
return UpdateSubscriptionResponse(
success=True,
message=f'Subscription shortened by {request.days} days',
subscription=await _build_subscription_info_async(db, subscription),
)
if request.action == 'set_end_date':
if not request.end_date:
raise HTTPException(
@@ -1081,13 +1121,38 @@ async def update_user_subscription(
detail='Tariff not found',
)
# Preserve extra purchased devices above the old tariff's base limit
extra_devices = 0
if subscription.tariff_id:
old_tariff = await get_tariff_by_id(db, subscription.tariff_id)
if old_tariff and old_tariff.device_limit:
extra_devices = max(0, (subscription.device_limit or old_tariff.device_limit) - old_tariff.device_limit)
from app.config import settings
subscription.tariff_id = request.tariff_id
subscription.traffic_limit_gb = tariff.traffic_limit_gb
subscription.device_limit = tariff.device_limit
new_base = tariff.device_limit or 1
new_total = new_base + extra_devices
# Cap at new tariff's max_device_limit, falling back to global MAX_DEVICES_LIMIT
effective_max = tariff.max_device_limit or (
settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
)
if effective_max and new_total > effective_max:
new_total = effective_max
subscription.device_limit = new_total
# Set squads from tariff
if tariff.allowed_squads:
subscription.connected_squads = tariff.allowed_squads
# Convert trial subscription to paid when switching to a non-trial tariff
if subscription.is_trial and not tariff.is_trial_available:
subscription.is_trial = False
if subscription.end_date and subscription.end_date > datetime.now(UTC):
subscription.status = SubscriptionStatus.ACTIVE.value
logger.info('Converted trial subscription to paid', user_id=user_id, tariff_name=tariff.name)
# Сбрасываем докупленный трафик при смене тарифа
from sqlalchemy import delete as sql_delete
@@ -1095,11 +1160,21 @@ async def update_user_subscription(
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
from app.config import settings
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
subscription.traffic_used_gb = 0.0
# Записываем транзакцию о смене тарифа
from app.database.crud.transaction import create_transaction
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=0,
description=f"Смена тарифа администратором на '{tariff.name}'",
commit=False,
)
await db.commit()
await db.refresh(subscription)
@@ -1211,7 +1286,7 @@ async def update_user_subscription(
await add_subscription_traffic(db, subscription, request.traffic_gb)
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
await reactivate_subscription(db, subscription)
await db.refresh(subscription)
@@ -1219,6 +1294,13 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
logger.info('Admin added traffic for user', admin_id=admin.id, traffic_gb=request.traffic_gb, user_id=user_id)
return UpdateSubscriptionResponse(
@@ -1572,8 +1654,22 @@ async def update_user_promo_group(
)
promo_group_name = promo_group.name
user.promo_group_id = new_promo_group_id
user.updated_at = datetime.now(UTC)
# Update M2M table (authoritative source) — not just the legacy FK column.
# Without this, sync_user_primary_promo_group overwrites the admin change
# on the next transaction.
await db.execute(sa_delete(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
if new_promo_group_id is not None:
db.add(
UserPromoGroup(
user_id=user_id,
promo_group_id=new_promo_group_id,
assigned_by='admin',
)
)
await db.flush()
await sync_user_primary_promo_group(db, user_id)
await db.commit()
await db.refresh(user)
@@ -1964,21 +2060,6 @@ async def reset_user_subscription(
panel_deactivated=False,
)
from app.database.crud.subscription import is_active_paid_subscription
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск сброса подписки: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
return ResetSubscriptionResponse(
success=False,
message='Cannot reset active paid subscription. Subscription is still active and paid.',
subscription_deleted=False,
panel_deactivated=False,
)
# Deactivate in Remnawave panel if requested
if request.deactivate_in_panel and user.remnawave_uuid:
try:
@@ -2163,14 +2244,18 @@ async def get_user_transactions(
result = await db.execute(query)
transactions = result.scalars().all()
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
_EXPENSE_TYPES = {
TransactionType.WITHDRAWAL.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
TransactionType.GIFT_PAYMENT.value,
}
items = [
UserTransactionItem(
id=t.id,
type=t.type,
amount_kopeks=abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
amount_kopeks=-abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=-abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
description=t.description,
payment_method=t.payment_method,
is_completed=t.is_completed,
@@ -2753,3 +2838,118 @@ async def sync_user_to_panel(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Sync error: {e!s}',
)
# === User Gifts ===
@router.get('/{user_id}/gifts', response_model=AdminUserGiftsResponse)
async def get_user_gifts(
user_id: int,
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> AdminUserGiftsResponse:
"""Get all gift subscriptions sent and received by user."""
from sqlalchemy.orm import noload
# Lightweight existence check (avoids eager-loading all User relationships)
user_exists = await db.execute(select(User.id).where(User.id == user_id))
if not user_exists.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
# True totals via COUNT queries
sent_total = (
await db.execute(
select(func.count(GuestPurchase.id)).where(
GuestPurchase.buyer_user_id == user_id,
GuestPurchase.is_gift.is_(True),
)
)
).scalar() or 0
received_total = (
await db.execute(
select(func.count(GuestPurchase.id)).where(
GuestPurchase.user_id == user_id,
GuestPurchase.is_gift.is_(True),
)
)
).scalar() or 0
# Sent gifts (user is buyer) — suppress unneeded relationships
sent_result = await db.execute(
select(GuestPurchase)
.options(
selectinload(GuestPurchase.tariff),
selectinload(GuestPurchase.user),
noload(GuestPurchase.buyer),
noload(GuestPurchase.landing),
)
.where(
GuestPurchase.buyer_user_id == user_id,
GuestPurchase.is_gift.is_(True),
)
.order_by(GuestPurchase.created_at.desc())
.limit(200)
)
sent_purchases = sent_result.scalars().all()
# Received gifts (user is recipient) — suppress unneeded relationships
received_result = await db.execute(
select(GuestPurchase)
.options(
selectinload(GuestPurchase.tariff),
selectinload(GuestPurchase.buyer),
noload(GuestPurchase.user),
noload(GuestPurchase.landing),
)
.where(
GuestPurchase.user_id == user_id,
GuestPurchase.is_gift.is_(True),
)
.order_by(GuestPurchase.created_at.desc())
.limit(200)
)
received_purchases = received_result.scalars().all()
sent_items = [_build_gift_item(p, receiver=p.user) for p in sent_purchases]
received_items = [_build_gift_item(p, buyer=p.buyer) for p in received_purchases]
return AdminUserGiftsResponse(
sent=sent_items,
received=received_items,
sent_total=sent_total,
received_total=received_total,
)
def _build_gift_item(
p: GuestPurchase,
receiver: User | None = None,
buyer: User | None = None,
) -> AdminUserGiftItem:
"""Build an admin gift item from a GuestPurchase."""
tariff_name = p.tariff.name if p.tariff else None
device_limit = p.tariff.device_limit if p.tariff else 1
return AdminUserGiftItem(
id=p.id,
token=p.token[:12],
status=p.status,
tariff_name=tariff_name,
period_days=p.period_days,
device_limit=device_limit,
amount_kopeks=p.amount_kopeks,
payment_method=p.payment_method,
gift_recipient_type=p.gift_recipient_type,
gift_recipient_value=p.gift_recipient_value,
gift_message=p.gift_message,
buyer_user_id=p.buyer_user_id,
buyer_username=buyer.username if buyer else None,
buyer_full_name=buyer.full_name if buyer else None,
receiver_user_id=p.user_id,
receiver_username=receiver.username if receiver else None,
receiver_full_name=receiver.full_name if receiver else None,
created_at=p.created_at,
paid_at=p.paid_at,
delivered_at=p.delivered_at,
)
+6 -6
View File
@@ -774,7 +774,7 @@ async def register_email(
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
custom_subject, custom_body = override or (None, None)
await asyncio.to_thread(
email_service.send_verification_email,
@@ -911,7 +911,7 @@ async def register_email_standalone(
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
custom_subject, custom_body = override or (None, None)
await asyncio.to_thread(
email_service.send_verification_email,
@@ -1049,7 +1049,7 @@ async def resend_verification(
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
custom_subject, custom_body = override or (None, None)
await asyncio.to_thread(
email_service.send_verification_email,
@@ -1356,7 +1356,7 @@ async def forgot_password(
context={'username': user.first_name or '', 'reset_url': full_url, 'expire_hours': str(expire_hours)},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
custom_subject, custom_body = override or (None, None)
await asyncio.to_thread(
email_service.send_password_reset_email,
@@ -1524,7 +1524,7 @@ async def request_email_change(
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
custom_subject, custom_body = override or (None, None)
try:
await asyncio.to_thread(
@@ -1579,7 +1579,7 @@ async def request_email_change(
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
custom_subject, custom_body = override or (None, None)
await asyncio.to_thread(
email_service.send_email_change_code,
+69 -8
View File
@@ -14,6 +14,10 @@ from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.saved_payment_method import (
deactivate_payment_method,
get_active_payment_methods_by_user,
)
from app.database.crud.user import get_user_by_id
from app.database.models import PaymentMethod, Transaction, User
from app.services.payment_method_config_service import get_enabled_methods_for_user
@@ -35,6 +39,8 @@ from ..schemas.balance import (
PaymentMethodResponse,
PendingPaymentListResponse,
PendingPaymentResponse,
SavedCardResponse,
SavedCardsListResponse,
StarsInvoiceRequest,
StarsInvoiceResponse,
TopUpRequest,
@@ -102,8 +108,8 @@ async def get_transactions(
for t in transactions:
# Determine sign based on transaction type
# Credits (positive): DEPOSIT, REFERRAL_REWARD, REFUND, POLL_REWARD
# Debits (negative): SUBSCRIPTION_PAYMENT, WITHDRAWAL
is_debit = t.type in ['subscription_payment', 'withdrawal']
# Debits (negative): SUBSCRIPTION_PAYMENT, WITHDRAWAL, GIFT_PAYMENT
is_debit = t.type in ['subscription_payment', 'withdrawal', 'gift_payment']
amount_kopeks = -abs(t.amount_kopeks) if is_debit else abs(t.amount_kopeks)
items.append(
@@ -198,7 +204,7 @@ async def get_payment_methods(
'description': description,
}
)
options = formatted_options if formatted_options else None
options = formatted_options or None
methods.append(
PaymentMethodResponse(
@@ -244,13 +250,16 @@ async def create_stars_invoice(
detail='Maximum amount is 10,000.00 RUB',
)
# Calculate Stars amount
# Calculate Stars amount and normalize kopeks to match exact star value
try:
amount_rubles = request.amount_kopeks / 100
stars_amount = settings.rubles_to_stars(amount_rubles)
if stars_amount <= 0:
stars_amount = 1
# Normalize kopeks so credited amount = stars * rate (no rounding mismatch)
normalized_kopeks = round(stars_amount * settings.get_stars_rate() * 100)
except Exception as e:
logger.error('Error calculating Stars amount', error=e)
raise HTTPException(
@@ -259,7 +268,7 @@ async def create_stars_invoice(
)
# Create payload for tracking payment
payload = f'balance_topup_{user.id}_{request.amount_kopeks}_{int(time.time())}'
payload = f'balance_topup_{user.id}_{normalized_kopeks}_{int(time.time())}'
# Create invoice through Telegram Bot API
try:
@@ -271,7 +280,7 @@ async def create_stars_invoice(
api_url,
json={
'title': 'Пополнение баланса VPN',
'description': f'Пополнение баланса на {amount_rubles:.2f} ₽ ({stars_amount} ⭐)',
'description': f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)',
'payload': payload,
'provider_token': '', # Empty for Stars
'currency': 'XTR',
@@ -299,7 +308,7 @@ async def create_stars_invoice(
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
amount_kopeks=request.amount_kopeks,
amount_kopeks=normalized_kopeks,
)
except httpx.HTTPError as e:
@@ -885,7 +894,7 @@ def _is_checkable(record: PendingPayment) -> bool:
if record.method == PaymentMethod.YOOKASSA:
return status in {'pending', 'waiting_for_capture'}
if record.method == PaymentMethod.CRYPTOBOT:
return status in {'active'}
return status == 'active'
if record.method == PaymentMethod.CLOUDPAYMENTS:
return status in {'pending', 'authorized'}
if record.method == PaymentMethod.FREEKASSA:
@@ -1171,3 +1180,55 @@ async def check_payment_status(
old_status=old_status,
new_status=updated.status,
)
@router.get('/saved-cards', response_model=SavedCardsListResponse)
async def get_saved_cards(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's saved payment methods (cards) for recurrent payments."""
recurrent_enabled = settings.YOOKASSA_RECURRENT_ENABLED
if not recurrent_enabled:
return SavedCardsListResponse(cards=[], recurrent_enabled=False)
methods = await get_active_payment_methods_by_user(db, user.id)
cards = [
SavedCardResponse(
id=m.id,
method_type=m.method_type,
card_last4=m.card_last4,
card_type=m.card_type,
title=m.title,
created_at=m.created_at,
)
for m in methods
]
return SavedCardsListResponse(cards=cards, recurrent_enabled=True)
@router.delete('/saved-cards/{card_id}', status_code=status.HTTP_200_OK)
async def delete_saved_card(
card_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unlink (deactivate) a saved payment method."""
if not settings.YOOKASSA_RECURRENT_ENABLED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Recurrent payments are not enabled',
)
success = await deactivate_payment_method(db, card_id, user.id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Saved card not found',
)
return {'success': True, 'message': 'Card unlinked successfully'}
+8 -7
View File
@@ -1,5 +1,6 @@
"""Branding routes for cabinet - logo, project name, and theme colors management."""
import asyncio
import json
import os
from pathlib import Path
@@ -401,7 +402,7 @@ async def get_logo():
"""
logo_path = get_logo_path()
if logo_path is None or not logo_path.exists():
if logo_path is None or not await asyncio.to_thread(logo_path.exists):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='No custom logo set')
# Determine media type from file extension
@@ -470,7 +471,7 @@ async def upload_logo(
)
# Ensure directory exists
ensure_branding_dir()
await asyncio.to_thread(ensure_branding_dir)
# Determine file extension from content type
ext_map = {
@@ -483,12 +484,12 @@ async def upload_logo(
extension = ext_map.get(file.content_type, '.png')
# Remove old logo files with any extension
for old_file in BRANDING_DIR.glob('logo.*'):
old_file.unlink()
for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))):
await asyncio.to_thread(old_file.unlink)
# Save new logo
logo_path = BRANDING_DIR / f'logo{extension}'
logo_path.write_bytes(content)
await asyncio.to_thread(logo_path.write_bytes, content)
# Mark that we have a custom logo
await set_setting_value(db, BRANDING_LOGO_KEY, 'custom')
@@ -517,8 +518,8 @@ async def delete_logo(
):
"""Delete custom logo and revert to letter. Admin only."""
# Remove logo files
for old_file in BRANDING_DIR.glob('logo.*'):
old_file.unlink()
for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))):
await asyncio.to_thread(old_file.unlink)
# Update setting
await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
+373 -52
View File
@@ -8,14 +8,22 @@ import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.landing import get_purchase_by_token
from app.database.crud.system_setting import get_setting_value
from app.database.crud.tariff import get_tariff_by_id
from app.database.crud.transaction import create_transaction, emit_transaction_side_effects
from app.database.crud.user import subtract_user_balance
from app.database.models import GuestPurchase, GuestPurchaseStatus, PaymentMethod, Tariff, TransactionType, User
from app.database.models import (
GuestPurchase,
GuestPurchaseStatus,
PaymentMethod,
Tariff,
TransactionType,
User,
UserPromoGroup,
)
from app.services.guest_purchase_service import (
GuestPurchaseError,
create_purchase,
@@ -23,9 +31,12 @@ from app.services.guest_purchase_service import (
)
from app.services.payment_method_config_service import get_enabled_methods_for_user
from app.utils.cache import RateLimitCache
from app.utils.promo_offer import get_user_active_promo_discount_percent
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.gift import (
ActivateGiftRequest,
ActivateGiftResponse,
GiftConfigPaymentMethod,
GiftConfigResponse,
GiftConfigSubOption,
@@ -35,6 +46,8 @@ from ..schemas.gift import (
GiftPurchaseResponse,
GiftPurchaseStatusResponse,
PendingGiftResponse,
ReceivedGiftResponse,
SentGiftResponse,
)
@@ -69,25 +82,61 @@ async def get_gift_config(
balance_kopeks=user.balance_kopeks,
)
# Load active tariffs
# Load active tariffs visible in gift section
result = await db.execute(
select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.display_order, Tariff.id)
select(Tariff)
.where(Tariff.is_active.is_(True), Tariff.show_in_gift.is_(True))
.order_by(Tariff.display_order, Tariff.id)
)
tariffs_db = result.scalars().all()
# Get user's promo group for discount calculation
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group is None:
promo_group = getattr(user, 'promo_group', None)
promo_group_name = promo_group.name if promo_group else None
# Get active promo offer discount
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
tariffs: list[GiftConfigTariff] = []
for tariff in tariffs_db:
period_days_list = tariff.get_available_periods()
periods: list[GiftConfigTariffPeriod] = []
for days in period_days_list:
price = tariff.get_price_for_period(days)
if price is None:
base_price = tariff.get_price_for_period(days)
if base_price is None:
continue
original_price = base_price
price = base_price
# Apply promo group discount
promo_group_discount = 0
if promo_group:
promo_group_discount = promo_group.get_discount_percent('period', days)
if promo_group_discount > 0:
price = int(price * (100 - promo_group_discount) / 100)
# Apply active promo offer discount (stacks on top)
if promo_offer_discount_percent > 0:
price = price - price * promo_offer_discount_percent // 100
# Ensure minimum price of 1 kopek after all discounts
price = max(1, price)
# Calculate combined discount percent
combined_discount = 0
if original_price > 0 and original_price != price:
combined_discount = int((original_price - price) * 100 / original_price)
periods.append(
GiftConfigTariffPeriod(
days=days,
price_kopeks=price,
price_label=settings.format_price(price),
original_price_kopeks=original_price if combined_discount > 0 else None,
discount_percent=combined_discount if combined_discount > 0 else None,
)
)
if not periods:
@@ -127,6 +176,11 @@ async def get_gift_config(
payment_methods=payment_methods,
balance_kopeks=user.balance_kopeks,
currency_symbol=getattr(settings, 'CURRENCY_SYMBOL', '\u20bd'),
promo_group_name=promo_group_name,
active_discount_percent=promo_offer_discount_percent if promo_offer_discount_percent > 0 else None,
active_discount_expires_at=(
getattr(user, 'promo_offer_discount_expires_at', None) if promo_offer_discount_percent > 0 else None
),
)
@@ -156,36 +210,40 @@ async def create_gift_purchase(
detail='Purchases are restricted for this account',
)
# Validate recipient format
if body.recipient_type == 'email' and not _EMAIL_RE.match(body.recipient_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid email format',
)
if body.recipient_type == 'telegram' and not _TELEGRAM_RE.match(body.recipient_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid Telegram username format',
)
# Recipient is optional — when omitted, buyer gets a code to share manually
has_recipient = bool(body.recipient_type and body.recipient_value)
# Prevent self-gift
if body.recipient_type == 'telegram':
normalized_recipient = body.recipient_value.lstrip('@').lower()
if user.username and user.username.lower() == normalized_recipient:
if has_recipient:
# Validate recipient format
if body.recipient_type == 'email' and not _EMAIL_RE.match(body.recipient_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot gift to yourself',
detail='Invalid email format',
)
elif body.recipient_type == 'email':
if user.email and user.email.lower() == body.recipient_value.lower():
if body.recipient_type == 'telegram' and not _TELEGRAM_RE.match(body.recipient_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot gift to yourself',
detail='Invalid Telegram username format',
)
# Prevent self-gift
if body.recipient_type == 'telegram':
normalized_recipient = body.recipient_value.lstrip('@').lower()
if user.username and user.username.lower() == normalized_recipient:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot gift to yourself',
)
elif body.recipient_type == 'email':
if user.email and user.email.lower() == body.recipient_value.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot gift to yourself',
)
# Find tariff and validate period
tariff = await get_tariff_by_id(db, body.tariff_id)
if tariff is None or not tariff.is_active:
if tariff is None or not tariff.is_active or not tariff.show_in_gift:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found or inactive',
@@ -198,6 +256,37 @@ async def create_gift_purchase(
detail='Price is not configured for this period',
)
# Lock user row to prevent concurrent promo offer double-spend
locked_result = await db.execute(
select(User)
.options(
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.promo_group),
)
.where(User.id == user.id)
.with_for_update()
.execution_options(populate_existing=True)
)
user = locked_result.scalar_one()
# Apply promo group discount
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group is None:
promo_group = getattr(user, 'promo_group', None)
if promo_group:
discount_percent = promo_group.get_discount_percent('period', body.period_days)
if discount_percent > 0:
price_kopeks = int(price_kopeks * (100 - discount_percent) / 100)
# Apply active promo offer discount (stacks)
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
if promo_offer_discount_percent > 0:
price_kopeks = price_kopeks - price_kopeks * promo_offer_discount_percent // 100
# Ensure minimum price of 1 kopek after all discounts
price_kopeks = max(1, price_kopeks)
# Determine buyer contact info
if user.email:
buyer_contact_type = 'email'
@@ -210,11 +299,10 @@ async def create_gift_purchase(
buyer_contact_value = f'id:{user.telegram_id or user.id}'
# Pre-check: try to resolve Telegram username — DB first, then Bot API.
# Placed after validation gates to prevent zero-cost enumeration.
# The resolved ID is passed to fulfill_purchase to avoid a duplicate API call.
# Only relevant when a recipient is explicitly specified.
recipient_warning: str | None = None
pre_resolved_telegram_id: int | None = None
if body.recipient_type == 'telegram':
if has_recipient and body.recipient_type == 'telegram':
tg_username = body.recipient_value.lstrip('@')
normalized_username = tg_username.lower()
@@ -253,6 +341,18 @@ async def create_gift_purchase(
detail='payment_method is required for gateway mode',
)
purchase_kwargs: dict = (
{
'gift_recipient_type': body.recipient_type,
'gift_recipient_value': body.recipient_value,
'gift_message': body.gift_message,
}
if has_recipient
else {
'gift_message': body.gift_message,
}
)
try:
purchase = await create_purchase(
db,
@@ -264,12 +364,10 @@ async def create_gift_purchase(
contact_value=buyer_contact_value,
payment_method=body.payment_method,
is_gift=True,
gift_recipient_type=body.recipient_type,
gift_recipient_value=body.recipient_value,
gift_message=body.gift_message,
source='cabinet',
buyer_user_id=user.id,
commit=False,
**purchase_kwargs,
)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
@@ -280,11 +378,18 @@ async def create_gift_purchase(
# Build return URL for after payment
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
return_url = f'{cabinet_base}/gift/result?token={purchase.token}'
return_url = f'{cabinet_base}/gift/result?token={purchase.token[:12]}'
from app.services.payment_service import PaymentService
payment_service = PaymentService()
# Stars payments need a Bot instance to create invoice links
bot = None
if body.payment_method == 'telegram_stars':
from aiogram import Bot
bot = Bot(token=settings.BOT_TOKEN)
payment_service = PaymentService(bot=bot)
payment_result = await payment_service.create_guest_payment(
db=db,
amount_kopeks=price_kopeks,
@@ -314,12 +419,18 @@ async def create_gift_purchase(
detail='Payment provider returned an invalid response',
)
# Consume promo offer discount before committing gateway purchase
if promo_offer_discount_percent > 0 and getattr(user, 'promo_offer_discount_percent', 0):
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
await db.commit()
await db.refresh(purchase)
return GiftPurchaseResponse(
status='created',
purchase_token=purchase.token,
purchase_token=purchase.token[:12],
payment_url=payment_url,
warning=recipient_warning,
)
@@ -332,6 +443,18 @@ async def create_gift_purchase(
)
# Create purchase record
balance_purchase_kwargs: dict = (
{
'gift_recipient_type': body.recipient_type,
'gift_recipient_value': body.recipient_value,
'gift_message': body.gift_message,
}
if has_recipient
else {
'gift_message': body.gift_message,
}
)
try:
purchase = await create_purchase(
db,
@@ -343,12 +466,10 @@ async def create_gift_purchase(
contact_value=buyer_contact_value,
payment_method='balance',
is_gift=True,
gift_recipient_type=body.recipient_type,
gift_recipient_value=body.recipient_value,
gift_message=body.gift_message,
source='cabinet',
buyer_user_id=user.id,
commit=False,
**balance_purchase_kwargs,
)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
@@ -357,13 +478,14 @@ async def create_gift_purchase(
if recipient_warning:
purchase.recipient_warning = recipient_warning
# Subtract balance
# Subtract balance (consume promo offer if one was applied)
balance_ok = await subtract_user_balance(
db,
user,
price_kopeks,
description=f'Gift: {tariff.name} ({body.period_days}d)',
create_transaction=False,
consume_promo_offer=promo_offer_discount_percent > 0,
)
if not balance_ok:
await db.rollback()
@@ -372,13 +494,18 @@ async def create_gift_purchase(
detail='Insufficient balance',
)
# Transaction description: include recipient when specified
tx_description = f'Gift: {tariff.name} ({body.period_days}d)'
if has_recipient:
tx_description += f' -> {body.recipient_value}'
# Create transaction record
transaction = await create_transaction(
db,
user_id=user.id,
type=TransactionType.GIFT_PAYMENT,
amount_kopeks=price_kopeks,
description=f'Gift: {tariff.name} ({body.period_days}d) -> {body.recipient_value}',
description=tx_description,
payment_method=PaymentMethod.BALANCE,
commit=False,
)
@@ -397,24 +524,26 @@ async def create_gift_purchase(
user_id=user.id,
type=TransactionType.GIFT_PAYMENT,
payment_method=PaymentMethod.BALANCE,
description=f'Gift: {tariff.name} ({body.period_days}d) -> {body.recipient_value}',
description=tx_description,
)
# Capture token before fulfill_purchase — session state may change after rollback inside fulfill
purchase_token = purchase.token
# Fulfill the purchase (find/create recipient user, create subscription, notify)
try:
await fulfill_purchase(db, purchase_token, pre_resolved_telegram_id=pre_resolved_telegram_id)
except Exception:
logger.exception(
'Gift purchase fulfillment failed (purchase is paid, will retry)',
purchase_id=purchase.id,
)
# Only fulfill immediately when a specific recipient was provided.
# Code-only gifts (no recipient) stay in PAID status until someone activates via code.
if has_recipient:
try:
await fulfill_purchase(db, purchase_token, pre_resolved_telegram_id=pre_resolved_telegram_id)
except Exception:
logger.exception(
'Gift purchase fulfillment failed (purchase is paid, will retry)',
purchase_id=purchase.id,
)
return GiftPurchaseResponse(
status='ok',
purchase_token=purchase_token,
purchase_token=purchase_token[:12],
warning=recipient_warning,
)
@@ -427,12 +556,14 @@ async def get_pending_gifts(
"""Get pending gift purchases that the current user can activate."""
result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff))
.where(
GuestPurchase.user_id == user.id,
GuestPurchase.is_gift.is_(True),
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
)
.order_by(GuestPurchase.created_at.desc())
.limit(100)
)
purchases = result.scalars().all()
@@ -445,7 +576,7 @@ async def get_pending_gifts(
pending.append(
PendingGiftResponse(
token=p.token,
token=p.token[:12],
tariff_name=p.tariff.name if p.tariff else None,
period_days=p.period_days,
gift_message=p.gift_message,
@@ -464,7 +595,13 @@ async def get_gift_purchase_status(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get the status of a cabinet gift purchase."""
purchase = await get_purchase_by_token(db, token)
if len(token) >= 64:
token_filter = GuestPurchase.token == token
else:
token_filter = GuestPurchase.token.startswith(token)
result = await db.execute(select(GuestPurchase).options(selectinload(GuestPurchase.tariff)).where(token_filter))
purchase = result.scalars().first()
if purchase is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -484,12 +621,196 @@ async def get_gift_purchase_status(
if purchase.gift_recipient_value:
recipient_contact_value = purchase.gift_recipient_value
is_code_only = purchase.is_gift and not purchase.gift_recipient_type
return GiftPurchaseStatusResponse(
status=purchase.status,
is_gift=True,
is_code_only=is_code_only,
purchase_token=purchase.token[:12] if is_code_only else None,
recipient_contact_value=recipient_contact_value,
gift_message=purchase.gift_message,
tariff_name=tariff_name,
period_days=purchase.period_days,
warning=purchase.recipient_warning,
)
@router.get('/sent', response_model=list[SentGiftResponse])
async def get_sent_gifts(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all gifts the current user has sent."""
result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff), selectinload(GuestPurchase.user))
.where(
GuestPurchase.buyer_user_id == user.id,
GuestPurchase.is_gift.is_(True),
)
.order_by(GuestPurchase.created_at.desc())
.limit(100)
)
purchases = result.scalars().all()
sent: list[SentGiftResponse] = []
for p in purchases:
activated_by_username = None
if p.status == GuestPurchaseStatus.DELIVERED.value and p.user and p.user.username:
activated_by_username = f'@{p.user.username}'
sent.append(
SentGiftResponse(
token=p.token[:12],
tariff_name=p.tariff.name if p.tariff else None,
period_days=p.period_days,
device_limit=p.tariff.device_limit if p.tariff else 1,
status=p.status,
gift_recipient_value=p.gift_recipient_value,
gift_message=p.gift_message,
activated_by_username=activated_by_username,
created_at=p.created_at,
)
)
return sent
@router.get('/received', response_model=list[ReceivedGiftResponse])
async def get_received_gifts(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all gifts the current user has received."""
result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff), selectinload(GuestPurchase.buyer))
.where(
GuestPurchase.user_id == user.id,
GuestPurchase.is_gift.is_(True),
)
.order_by(GuestPurchase.created_at.desc())
.limit(100)
)
purchases = result.scalars().all()
received: list[ReceivedGiftResponse] = []
for p in purchases:
sender_display = None
if p.buyer and p.buyer.username:
sender_display = f'@{p.buyer.username}'
elif p.contact_value:
sender_display = p.contact_value
received.append(
ReceivedGiftResponse(
token=p.token[:12],
tariff_name=p.tariff.name if p.tariff else None,
period_days=p.period_days,
device_limit=p.tariff.device_limit if p.tariff else 1,
status=p.status,
sender_display=sender_display,
gift_message=p.gift_message,
created_at=p.created_at,
)
)
return received
@router.post('/activate', response_model=ActivateGiftResponse)
async def activate_gift_by_code(
body: ActivateGiftRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Activate a gift subscription by its code (token)."""
from app.services.guest_purchase_service import activate_purchase as svc_activate
# Bug 2 fix: rate limit activation attempts to prevent brute-force token enumeration
is_limited = await RateLimitCache.is_rate_limited(user.id, 'gift_activate', limit=10, window=60)
if is_limited:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
code = body.code.strip()
if code.upper().startswith('GIFT-'):
code = code[5:]
if len(code) < 8:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Code too short')
# Support both full token and prefix-based lookup (displayed codes are truncated)
if len(code) >= 64:
# Full token — exact match
token_filter = GuestPurchase.token == code
else:
# Prefix match — for short display codes like GIFT-XXXXXXXXXXXX
token_filter = GuestPurchase.token.startswith(code)
result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff))
.where(token_filter, GuestPurchase.is_gift.is_(True))
.with_for_update()
)
purchase = result.scalars().first()
if purchase is None or not purchase.is_gift:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Gift not found',
)
# Bug 1 fix: check ownership BEFORE leaking any status/tariff info
if purchase.user_id is not None and purchase.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Gift not found',
)
# Prevent self-activation: buyer cannot activate their own gift
if purchase.buyer_user_id is not None and purchase.buyer_user_id == user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot activate your own gift',
)
if purchase.status == GuestPurchaseStatus.DELIVERED.value:
return ActivateGiftResponse(
status='activated',
tariff_name=purchase.tariff.name if purchase.tariff else None,
period_days=purchase.period_days,
)
# Code-only gifts are in PAID status; directed gifts are in PENDING_ACTIVATION
activatable_statuses = {
GuestPurchaseStatus.PENDING_ACTIVATION.value,
GuestPurchaseStatus.PAID.value,
}
if purchase.status not in activatable_statuses:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This gift cannot be activated',
)
# For code-only gifts (user_id is None), link the purchase to the activating user
if purchase.user_id is None:
purchase.user_id = user.id
# Transition PAID → PENDING_ACTIVATION so activate_purchase() accepts it
if purchase.status == GuestPurchaseStatus.PAID.value:
purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
await db.flush()
try:
await svc_activate(db, purchase.token, skip_notification=True)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
return ActivateGiftResponse(
status='activated',
tariff_name=purchase.tariff.name if purchase.tariff else None,
period_days=purchase.period_days,
)
+1 -1
View File
@@ -160,7 +160,7 @@ async def get_rules(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get service rules - uses same function as bot."""
requested_lang = language.split('-')[0].lower()
requested_lang = language.split('-', maxsplit=1)[0].lower()
# Use the same function as bot to ensure consistent content
content = await get_current_rules_content(db, requested_lang)
+7
View File
@@ -607,6 +607,13 @@ async def create_landing_purchase(
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
# Gift purchases require the tariff to be visible in the gift section
if body.is_gift and not tariff.show_in_gift:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This tariff is not available for gift purchases',
)
# Validate amount against per-method min/max limits (before creating purchase record)
min_amount = method_config.get('min_amount_kopeks')
max_amount = method_config.get('max_amount_kopeks')
+2 -2
View File
@@ -92,8 +92,7 @@ async def get_referral_info(
available_balance = min(user.balance_kopeks, referral_entitlement)
# Build referral link
bot_username = settings.get_bot_username() or 'bot'
referral_link = f'https://t.me/{bot_username}?start={user.referral_code}'
referral_link = settings.get_referral_link(user.referral_code) if user.referral_code else ''
return ReferralInfoResponse(
referral_code=user.referral_code or '',
@@ -243,5 +242,6 @@ async def get_referral_terms():
first_topup_bonus_rubles=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100,
inviter_bonus_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
inviter_bonus_rubles=settings.REFERRAL_INVITER_BONUS_KOPEKS / 100,
max_commission_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
)
+53 -35
View File
@@ -98,12 +98,13 @@ def _apply_addon_discount(
Returns dict with keys: discounted, discount, percent
"""
from app.utils.pricing_utils import apply_percentage_discount
percent = _get_addon_discount_percent(user, category, period_days)
if percent <= 0 or amount <= 0:
return {'discounted': amount, 'discount': 0, 'percent': 0}
discount_value = int(amount * percent / 100)
discounted_amount = amount - discount_value
discounted_amount, discount_value = apply_percentage_discount(amount, percent)
return {
'discounted': discounted_amount,
'discount': discount_value,
@@ -860,15 +861,15 @@ async def purchase_traffic(
# Пропорциональный расчёт применяем только в классическом режиме.
if is_tariff_mode:
prorated_price = base_price_kopeks
months_charged = 1
days_charged = 30
else:
prorated_price, months_charged = calculate_prorated_price(
prorated_price, days_charged = calculate_prorated_price(
base_price_kopeks,
subscription.end_date,
)
# Apply discount from promo group using proper method
period_hint_days = months_charged * 30 if months_charged > 0 else 30
period_hint_days = days_charged if days_charged > 0 else 30
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, period_hint_days)
final_price = discount_result['discounted']
traffic_discount_percent = discount_result['percent']
@@ -933,7 +934,7 @@ async def purchase_traffic(
# Добавляем трафик (add_subscription_traffic обновляет purchased_traffic_gb, traffic_reset_at и коммитит)
await add_subscription_traffic(db, subscription, request.gb)
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
from app.database.crud.subscription import reactivate_subscription
await reactivate_subscription(db, subscription)
@@ -943,6 +944,9 @@ async def purchase_traffic(
subscription_service = SubscriptionService()
if getattr(user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if subscription.status == 'active':
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
else:
await subscription_service.create_remnawave_user(db, subscription)
except Exception as e:
@@ -1134,6 +1138,7 @@ async def purchase_devices_legacy(
description=description,
create_transaction=True,
payment_method=PaymentMethod.BALANCE,
transaction_type=TransactionType.SUBSCRIPTION_PAYMENT,
)
if not success:
raise HTTPException(
@@ -1468,7 +1473,7 @@ async def activate_trial(
duration_days=trial_duration,
traffic_limit_gb=trial_traffic_limit,
device_limit=trial_device_limit,
connected_squads=trial_squads if trial_squads else None,
connected_squads=trial_squads or None,
tariff_id=tariff_id_for_trial,
)
@@ -1903,7 +1908,7 @@ async def submit_purchase(
period_days=selection.period.days,
was_trial_conversion=result.get('was_trial_conversion', False),
amount_kopeks=pricing.final_total,
purchase_type='renewal' if not is_new_subscription else None,
purchase_type='renewal' if not is_new_subscription else 'first_purchase',
)
finally:
await bot.session.close()
@@ -2356,7 +2361,7 @@ async def purchase_tariff(
period_days=period_days,
was_trial_conversion=False,
amount_kopeks=price_kopeks,
purchase_type='renewal' if not was_new_subscription else None,
purchase_type='renewal' if not was_new_subscription else 'first_purchase',
)
finally:
await bot.session.close()
@@ -2522,6 +2527,7 @@ async def purchase_devices(
description=description,
create_transaction=True,
payment_method=PaymentMethod.BALANCE,
transaction_type=TransactionType.SUBSCRIPTION_PAYMENT,
)
if not success:
raise HTTPException(
@@ -2644,7 +2650,6 @@ async def save_traffic_cart(
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, bool]:
"""Save cart for traffic purchase (for insufficient balance flow)."""
from app.utils.pricing_utils import calculate_prorated_price
await db.refresh(user, ['subscription'])
subscription = user.subscription
@@ -2715,26 +2720,18 @@ async def save_traffic_cart(
)
base_price_kopeks = matching_pkg['price']
# Apply promo group discount
traffic_discount_percent = 0
promo_group = (
user.get_primary_promo_group()
if hasattr(user, 'get_primary_promo_group')
else getattr(user, 'promo_group', None)
)
if promo_group:
apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True)
if apply_to_addons:
traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0)))
# Calculate prorated price (days-based), then apply discount
from app.utils.pricing_utils import calculate_prorated_price as _calc_prorated
if traffic_discount_percent > 0:
base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100)
# Calculate prorated price
final_price, _ = calculate_prorated_price(
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
prorated_price, _ = _calc_prorated(
base_price_kopeks,
subscription.end_date,
)
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, days_left)
final_price = discount_result['discounted']
traffic_discount_percent = discount_result['percent']
# Save cart for auto-purchase after balance top-up
cart_data = {
@@ -2812,14 +2809,26 @@ async def save_devices_cart(
days_left = max(1, (end_date - now).days)
total_days = 30
price_kopeks = int(device_price * request.devices * days_left / total_days)
price_kopeks = max(100, price_kopeks) # Minimum 1 ruble
base_total_price = int(device_price * request.devices * days_left / total_days)
base_total_price = max(100, base_total_price) # Minimum 1 ruble
# Apply discount from promo group
period_hint_days = days_left
discount_result = _apply_addon_discount(user, 'devices', base_total_price, period_hint_days)
price_kopeks = discount_result['discounted']
devices_discount_percent = discount_result['percent']
# Ensure minimum price after discount (except for 100% discount)
if devices_discount_percent < 100 and price_kopeks > 0:
price_kopeks = max(100, price_kopeks)
# Save cart for auto-purchase after balance top-up
cart_data = {
'cart_mode': 'add_devices',
'devices_to_add': request.devices,
'price_kopeks': price_kopeks,
'base_price_kopeks': base_total_price,
'discount_percent': devices_discount_percent,
'source': 'cabinet',
}
await user_cart_service.save_user_cart(user.id, cart_data)
@@ -2897,10 +2906,9 @@ async def get_device_price(
days_left = max(1, (end_date - now).days)
total_days = 30
# Calculate base price before discount
base_price_per_device = int(device_price * days_left / total_days)
base_price_per_device = max(100, base_price_per_device)
base_total_price = base_price_per_device * devices
# Calculate base price before discount (total first, then floor)
base_total_price = int(device_price * devices * days_left / total_days)
base_total_price = max(100, base_total_price)
# Apply discount from promo group
period_hint_days = days_left
@@ -2909,7 +2917,7 @@ async def get_device_price(
devices_discount_percent = discount_result['percent']
discount_value = discount_result['discount']
# Calculate per-device price after discount
# Ensure minimum price after discount (except for 100% discount)
if devices_discount_percent < 100 and total_price_kopeks > 0:
total_price_kopeks = max(100, total_price_kopeks)
price_per_device_kopeks = total_price_kopeks // devices if devices > 0 else 0
@@ -3256,7 +3264,7 @@ async def update_countries(
else:
discounted_per_month = server_price_per_month
charged_price, charged_months = calculate_prorated_price(
charged_price, charged_days = calculate_prorated_price(
discounted_per_month,
user.subscription.end_date,
)
@@ -4345,6 +4353,16 @@ async def switch_tariff(
description=description,
payment_method=PaymentMethod.BALANCE,
)
else:
# Free switch (downgrade) — record in history
description = f"Переход на тариф '{new_tariff.name}'"
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=0,
description=description,
)
# Update subscription
old_tariff_name = current_tariff.name if current_tariff else 'Unknown'
@@ -4668,7 +4686,7 @@ async def switch_traffic_package(
price_diff = int(price_diff * (100 - traffic_discount_percent) / 100)
# Prorated calculation
final_price, months_charged = calculate_prorated_price(price_diff, user.subscription.end_date)
final_price, days_charged = calculate_prorated_price(price_diff, user.subscription.end_date)
if user.balance_kopeks < final_price:
raise HTTPException(
+23 -5
View File
@@ -3,7 +3,7 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
class BalanceResponse(BaseModel):
@@ -26,8 +26,7 @@ class TransactionResponse(BaseModel):
created_at: datetime
completed_at: datetime | None = None
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class TransactionListResponse(BaseModel):
@@ -114,8 +113,7 @@ class PendingPaymentResponse(BaseModel):
user_telegram_id: int | None = None
user_username: str | None = None
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class PendingPaymentListResponse(BaseModel):
@@ -137,3 +135,23 @@ class ManualCheckResponse(BaseModel):
status_changed: bool = False
old_status: str | None = None
new_status: str | None = None
class SavedCardResponse(BaseModel):
"""Saved payment method (card) for recurrent payments."""
id: int
method_type: str
card_last4: str | None = None
card_type: str | None = None
title: str | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class SavedCardsListResponse(BaseModel):
"""List of saved payment methods."""
cards: list[SavedCardResponse]
recurrent_enabled: bool = False
+44 -2
View File
@@ -45,13 +45,16 @@ class GiftConfigResponse(BaseModel):
payment_methods: list[GiftConfigPaymentMethod] = []
balance_kopeks: int = 0
currency_symbol: str = '\u20bd'
promo_group_name: str | None = None
active_discount_percent: int | None = None
active_discount_expires_at: datetime | None = None
class GiftPurchaseRequest(BaseModel):
tariff_id: int = Field(gt=0)
period_days: int = Field(gt=0, le=3650)
recipient_type: str = Field(pattern=r'^(email|telegram)$')
recipient_value: str = Field(min_length=1, max_length=255)
recipient_type: str | None = Field(default=None, pattern=r'^(email|telegram)$')
recipient_value: str | None = Field(default=None, max_length=255)
gift_message: str | None = Field(default=None, max_length=1000)
payment_mode: str = Field(pattern=r'^(balance|gateway)$')
payment_method: str | None = Field(default=None, max_length=50)
@@ -73,6 +76,8 @@ class GiftPurchaseResponse(BaseModel):
class GiftPurchaseStatusResponse(BaseModel):
status: str
is_gift: bool = True
is_code_only: bool = False
purchase_token: str | None = None
recipient_contact_value: str | None = None
gift_message: str | None = None
tariff_name: str | None = None
@@ -87,3 +92,40 @@ class PendingGiftResponse(BaseModel):
gift_message: str | None = None
sender_display: str | None = None
created_at: datetime | None = None
class SentGiftResponse(BaseModel):
"""A gift the current user has sent."""
token: str
tariff_name: str | None = None
period_days: int
device_limit: int = 1
status: str
gift_recipient_value: str | None = None
gift_message: str | None = None
activated_by_username: str | None = None
created_at: datetime | None = None
class ReceivedGiftResponse(BaseModel):
"""A gift the current user has received."""
token: str
tariff_name: str | None = None
period_days: int
device_limit: int = 1
status: str
sender_display: str | None = None
gift_message: str | None = None
created_at: datetime | None = None
class ActivateGiftRequest(BaseModel):
code: str = Field(min_length=1, max_length=100)
class ActivateGiftResponse(BaseModel):
status: str
tariff_name: str | None = None
period_days: int | None = None
+1
View File
@@ -80,4 +80,5 @@ class ReferralTermsResponse(BaseModel):
first_topup_bonus_rubles: float
inviter_bonus_kopeks: int
inviter_bonus_rubles: float
max_commission_payments: int = 0
partner_section_visible: bool = True
+19
View File
@@ -54,6 +54,7 @@ class TariffListItem(BaseModel):
is_daily: bool = False
daily_price_kopeks: int = 0
allow_traffic_topup: bool = True
show_in_gift: bool = True
traffic_limit_gb: int
device_limit: int
tier_level: int
@@ -114,6 +115,8 @@ class TariffDetailResponse(BaseModel):
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = None
# Показывать в подарках
show_in_gift: bool = True
created_at: datetime
updated_at: datetime | None = None
@@ -170,6 +173,8 @@ class TariffCreateRequest(BaseModel):
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
# Показывать в подарках
show_in_gift: bool = True
class TariffUpdateRequest(BaseModel):
@@ -209,6 +214,8 @@ class TariffUpdateRequest(BaseModel):
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
# Показывать в подарках
show_in_gift: bool | None = None
class TariffSortOrderRequest(BaseModel):
@@ -243,3 +250,15 @@ class TariffStatsResponse(BaseModel):
trial_subscriptions: int
revenue_kopeks: int
revenue_rubles: float
class SyncSquadsResponse(BaseModel):
"""Response after syncing squads for tariff subscriptions."""
tariff_id: int
tariff_name: str
total_subscriptions: int
updated_count: int
failed_count: int
skipped_count: int
errors: list[str] = Field(default_factory=list)
+42 -5
View File
@@ -1,13 +1,13 @@
"""Schemas for Admin Users management in cabinet."""
from datetime import datetime
from enum import Enum
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, Field
class UserStatusEnum(str, Enum):
class UserStatusEnum(StrEnum):
"""User status enum."""
ACTIVE = 'active'
@@ -15,7 +15,7 @@ class UserStatusEnum(str, Enum):
DELETED = 'deleted'
class SubscriptionStatusEnum(str, Enum):
class SubscriptionStatusEnum(StrEnum):
"""Subscription status enum."""
TRIAL = 'trial'
@@ -25,7 +25,7 @@ class SubscriptionStatusEnum(str, Enum):
PENDING = 'pending'
class SortByEnum(str, Enum):
class SortByEnum(StrEnum):
"""Sort options for users list."""
CREATED_AT = 'created_at'
@@ -281,7 +281,7 @@ class UpdateSubscriptionRequest(BaseModel):
"""Request to update user subscription."""
action: str = Field(
..., description='Action: extend, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
..., description='Action: extend, shorten, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
)
# For extend action
@@ -696,3 +696,40 @@ class DisableUserResponse(BaseModel):
panel_deactivated: bool = False
user_blocked: bool = False
panel_error: str | None = None
# === Gifts ===
class AdminUserGiftItem(BaseModel):
"""Gift item for admin user detail view."""
id: int
token: str
status: str
tariff_name: str | None = None
period_days: int
device_limit: int = 1
amount_kopeks: int
payment_method: str | None = None
gift_recipient_type: str | None = None
gift_recipient_value: str | None = None
gift_message: str | None = None
buyer_user_id: int | None = None
buyer_username: str | None = None
buyer_full_name: str | None = None
receiver_user_id: int | None = None
receiver_username: str | None = None
receiver_full_name: str | None = None
created_at: datetime | None = None
paid_at: datetime | None = None
delivered_at: datetime | None = None
class AdminUserGiftsResponse(BaseModel):
"""Response with sent and received gifts for admin user detail."""
sent: list[AdminUserGiftItem] = []
received: list[AdminUserGiftItem] = []
sent_total: int = 0
received_total: int = 0
+3 -3
View File
@@ -1,7 +1,7 @@
"""Схемы для колеса удачи (Fortune Wheel)."""
from datetime import datetime
from enum import Enum
from enum import StrEnum
from pydantic import BaseModel, Field
@@ -9,14 +9,14 @@ from pydantic import BaseModel, Field
# ==================== ENUMS ====================
class WheelPaymentType(str, Enum):
class WheelPaymentType(StrEnum):
"""Способы оплаты спина."""
TELEGRAM_STARS = 'telegram_stars'
SUBSCRIPTION_DAYS = 'subscription_days'
class WheelPrizeType(str, Enum):
class WheelPrizeType(StrEnum):
"""Типы призов."""
SUBSCRIPTION_DAYS = 'subscription_days'
+67 -23
View File
@@ -1,7 +1,6 @@
import hashlib
import hmac
import html
import math
import os
import re
from collections import defaultdict
@@ -220,6 +219,7 @@ class Settings(BaseSettings):
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
REFERRAL_INVITER_BONUS_KOPEKS: int = 10000
REFERRAL_COMMISSION_PERCENT: int = 25
REFERRAL_MAX_COMMISSION_PAYMENTS: int = 0 # Макс. кол-во платежей реферала с комиссией (0 = без лимита)
REFERRAL_PROGRAM_ENABLED: bool = True
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
@@ -354,6 +354,8 @@ class Settings(BaseSettings):
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
YOOKASSA_RECURRENT_ENABLED: bool = False
YOOKASSA_RECURRENT_REQUIRED: bool = False
DISABLE_TOPUP_BUTTONS: bool = False
SUPPORT_TOPUP_ENABLED: bool = True
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
@@ -534,6 +536,18 @@ class Settings(BaseSettings):
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
# RioPay (api.riopay.online) v2.0.1
RIOPAY_ENABLED: bool = False
RIOPAY_API_TOKEN: str | None = None # x-api-token header
RIOPAY_WEBHOOK_SECRET: str | None = None # HMAC-SHA512 ключ для вебхуков (по умолчанию = API_TOKEN)
RIOPAY_DISPLAY_NAME: str = 'RioPay'
RIOPAY_CURRENCY: str = 'RUB'
RIOPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
RIOPAY_MAX_AMOUNT_KOPEKS: int = 100000000 # 1 000 000₽
RIOPAY_WEBHOOK_PATH: str = '/riopay-webhook'
RIOPAY_SUCCESS_URL: str | None = None
RIOPAY_FAIL_URL: str | None = None
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
CABINET_BUTTON_STYLE: str = ''
@@ -937,12 +951,12 @@ class Settings(BaseSettings):
def get_test_email(self) -> str | None:
"""Get test email for development/testing."""
email = (self.TEST_EMAIL or '').strip().lower()
return email if email else None
return email or None
def get_test_email_password(self) -> str | None:
"""Get test email password."""
password = (self.TEST_EMAIL_PASSWORD or '').strip()
return password if password else None
return password or None
def is_test_email(self, email: str) -> bool:
"""Check if email is the configured test email."""
@@ -1401,6 +1415,26 @@ class Settings(BaseSettings):
return value
return None
_CABINET_URL_DEFAULT = 'https://example.com/cabinet'
def get_referral_link(self, referral_code: str, bot_username: str | None = None) -> str:
"""Build a referral link pointing to the web cabinet.
Falls back to a Telegram bot deep link when CABINET_URL is not configured.
"""
from urllib.parse import quote
if not referral_code:
raise ValueError('referral_code must not be empty or None')
safe_code = quote(referral_code, safe='')
cabinet_url = (self.CABINET_URL or '').strip().rstrip('/')
if cabinet_url and cabinet_url != self._CABINET_URL_DEFAULT:
sep = '&' if '?' in cabinet_url else '?'
return f'{cabinet_url}{sep}ref={safe_code}'
username = bot_username or self.get_bot_username() or 'bot'
return f'https://t.me/{username}?start={safe_code}'
def is_deep_links_enabled(self) -> bool:
return self.ENABLE_DEEP_LINKS
@@ -1507,7 +1541,7 @@ class Settings(BaseSettings):
except (ValueError, IndexError):
continue
return packages if packages else self.get_traffic_packages()
return packages or self.get_traffic_packages()
def get_traffic_topup_price(self, gb: int | None) -> int:
"""Возвращает цену докупки для указанного количества ГБ."""
@@ -1597,7 +1631,7 @@ class Settings(BaseSettings):
def get_yookassa_display_name(self) -> str:
name = (self.YOOKASSA_DISPLAY_NAME or '').strip()
return name if name else 'YooKassa'
return name or 'YooKassa'
def is_nalogo_enabled(self) -> bool:
return self.NALOGO_ENABLED and self.NALOGO_INN is not None and self.NALOGO_PASSWORD is not None
@@ -1617,14 +1651,14 @@ class Settings(BaseSettings):
def get_cryptobot_display_name(self) -> str:
name = (self.CRYPTOBOT_DISPLAY_NAME or '').strip()
return name if name else 'CryptoBot'
return name or 'CryptoBot'
def is_heleket_enabled(self) -> bool:
return self.HELEKET_ENABLED and self.HELEKET_MERCHANT_ID is not None and self.HELEKET_API_KEY is not None
def get_heleket_display_name(self) -> str:
name = (self.HELEKET_DISPLAY_NAME or '').strip()
return name if name else 'Heleket Crypto'
return name or 'Heleket Crypto'
def is_mulenpay_enabled(self) -> bool:
return (
@@ -1662,7 +1696,7 @@ class Settings(BaseSettings):
def get_pal24_display_name(self) -> str:
name = (self.PAL24_DISPLAY_NAME or '').strip()
return name if name else 'PAL24'
return name or 'PAL24'
def is_platega_enabled(self) -> bool:
return self.PLATEGA_ENABLED and self.PLATEGA_MERCHANT_ID is not None and self.PLATEGA_SECRET is not None
@@ -1742,7 +1776,7 @@ class Settings(BaseSettings):
def get_wata_display_name(self) -> str:
name = (self.WATA_DISPLAY_NAME or '').strip()
return name if name else 'Wata'
return name or 'Wata'
def is_cloudpayments_enabled(self) -> bool:
return (
@@ -1753,7 +1787,7 @@ class Settings(BaseSettings):
def get_cloudpayments_display_name(self) -> str:
name = (self.CLOUDPAYMENTS_DISPLAY_NAME or '').strip()
return name if name else 'CloudPayments'
return name or 'CloudPayments'
def is_freekassa_enabled(self) -> bool:
return (
@@ -1766,7 +1800,7 @@ class Settings(BaseSettings):
def get_freekassa_display_name(self) -> str:
name = (self.FREEKASSA_DISPLAY_NAME or '').strip()
return name if name else 'Freekassa'
return name or 'Freekassa'
def get_freekassa_display_name_html(self) -> str:
return html.escape(self.get_freekassa_display_name())
@@ -1776,7 +1810,7 @@ class Settings(BaseSettings):
def get_freekassa_sbp_display_name(self) -> str:
name = (self.FREEKASSA_SBP_DISPLAY_NAME or '').strip()
return name if name else 'СБП (QR код)'
return name or 'СБП (QR код)'
def get_freekassa_sbp_display_name_html(self) -> str:
return html.escape(self.get_freekassa_sbp_display_name())
@@ -1786,7 +1820,7 @@ class Settings(BaseSettings):
def get_freekassa_card_display_name(self) -> str:
name = (self.FREEKASSA_CARD_DISPLAY_NAME or '').strip()
return name if name else 'Карта РФ'
return name or 'Карта РФ'
def get_freekassa_card_display_name_html(self) -> str:
return html.escape(self.get_freekassa_card_display_name())
@@ -1801,11 +1835,21 @@ class Settings(BaseSettings):
def get_kassa_ai_display_name(self) -> str:
name = (self.KASSA_AI_DISPLAY_NAME or '').strip()
return name if name else 'KassaAI'
return name or 'KassaAI'
def get_kassa_ai_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_display_name())
def is_riopay_enabled(self) -> bool:
return self.RIOPAY_ENABLED and self.RIOPAY_API_TOKEN is not None
def get_riopay_display_name(self) -> str:
name = (self.RIOPAY_DISPLAY_NAME or '').strip()
return name or 'RioPay'
def get_riopay_display_name_html(self) -> str:
return html.escape(self.get_riopay_display_name())
def is_payment_verification_auto_check_enabled(self) -> bool:
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
@@ -1901,7 +1945,7 @@ class Settings(BaseSettings):
'windows': ((self.HAPP_DOWNLOAD_LINK_WINDOWS or '').strip() or (self.HAPP_DOWNLOAD_LINK_PC or '').strip()),
}
link = links.get(platform_key)
return link if link else None
return link or None
def is_maintenance_mode(self) -> bool:
return self.MAINTENANCE_MODE
@@ -1989,7 +2033,7 @@ class Settings(BaseSettings):
# т.к. в режиме classic цена складывается из серверов/трафика/устройств)
periods = sorted(allowed_periods)
return periods if periods else [30, 90, 180]
return periods or [30, 90, 180]
def get_available_renewal_periods(self) -> list[int]:
"""
@@ -2014,7 +2058,7 @@ class Settings(BaseSettings):
# Возвращаем только разрешённые периоды (без фильтрации по цене)
periods = sorted(allowed_periods)
return periods if periods else [30, 90, 180]
return periods or [30, 90, 180]
def get_configured_subscription_periods(self) -> list[int]:
"""
@@ -2079,7 +2123,7 @@ class Settings(BaseSettings):
def get_telegram_stars_display_name(self) -> str:
name = (self.TELEGRAM_STARS_DISPLAY_NAME or '').strip()
return name if name else 'Telegram Stars'
return name or 'Telegram Stars'
def stars_to_rubles(self, stars: int) -> float:
return stars * self.get_stars_rate()
@@ -2088,7 +2132,7 @@ class Settings(BaseSettings):
rate = self.get_stars_rate()
if rate <= 0:
raise ValueError('Stars rate must be positive')
return max(1, math.ceil(rubles / rate))
return max(1, round(rubles / rate))
def get_admin_notifications_chat_id(self) -> int | None:
if not self.ADMIN_NOTIFICATIONS_CHAT_ID:
@@ -2116,7 +2160,7 @@ class Settings(BaseSettings):
def get_backup_archive_password(self) -> str | None:
password = (self.BACKUP_ARCHIVE_PASSWORD or '').strip()
return password if password else None
return password or None
# === Log Rotation Methods ===
@@ -2197,7 +2241,7 @@ class Settings(BaseSettings):
except ValueError:
continue
return packages if packages else self._get_fallback_traffic_packages()
return packages or self._get_fallback_traffic_packages()
except Exception as e:
logger.warning('ERROR PARSING CONFIG', error=e)
@@ -2330,13 +2374,13 @@ class Settings(BaseSettings):
if contact.startswith(('t.me/', 'telegram.me/', 'telegram.dog/')):
url = self.get_support_contact_url()
return url if url else contact
return url or contact
contact_without_prefix = contact.lstrip('@')
if '.' in contact_without_prefix:
url = self.get_support_contact_url()
return url if url else contact
return url or contact
if re.fullmatch(r'[A-Za-z0-9_]{3,}', contact_without_prefix):
return f'@{contact_without_prefix}'
+3
View File
@@ -5,6 +5,7 @@ from sqlalchemy import and_, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.crud.transaction import REAL_PAYMENT_METHODS
from app.database.models import (
AdvertisingCampaign,
AdvertisingCampaignRegistration,
@@ -268,11 +269,13 @@ async def get_campaign_statistics(
)
subscription_bonuses_issued = subscription_count_result.scalar() or 0
# Only count real deposits (exclude promo bonuses, wheel prizes, admin top-ups)
deposits_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed.is_(True),
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
)
)
deposits_total = deposits_result.scalar() or 0
+3 -2
View File
@@ -48,9 +48,10 @@ async def record_notification(
await db.commit()
async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
async def clear_notifications(db: AsyncSession, subscription_id: int, *, commit: bool = True) -> None:
await db.execute(delete(SentNotification).where(SentNotification.subscription_id == subscription_id))
await db.commit()
if commit:
await db.commit()
async def clear_notification_by_type(
+14
View File
@@ -50,6 +50,20 @@ async def create_referral_earning(
return earning
async def get_commission_payment_count(db: AsyncSession, referrer_id: int, referral_id: int) -> int:
"""Подсчитать количество комиссионных начислений реферера за платежи конкретного реферала."""
result = await db.execute(
select(func.count(ReferralEarning.id)).where(
and_(
ReferralEarning.user_id == referrer_id,
ReferralEarning.referral_id == referral_id,
ReferralEarning.reason == 'referral_commission_topup',
)
)
)
return result.scalar() or 0
async def get_referral_earnings_by_user(
db: AsyncSession, user_id: int, limit: int = 50, offset: int = 0
) -> list[ReferralEarning]:
+132
View File
@@ -0,0 +1,132 @@
"""CRUD операции для платежей RioPay."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import RioPayPayment
logger = structlog.get_logger(__name__)
async def create_riopay_payment(
db: AsyncSession,
*,
user_id: int,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
riopay_order_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> RioPayPayment:
"""Создает запись о платеже RioPay."""
payment = RioPayPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
riopay_order_id=riopay_order_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж RioPay', order_id=order_id, user_id=user_id)
return payment
async def get_riopay_payment_by_order_id(db: AsyncSession, order_id: str) -> RioPayPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(RioPayPayment).where(RioPayPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_riopay_payment_by_riopay_order_id(db: AsyncSession, riopay_order_id: str) -> RioPayPayment | None:
"""Получает платеж по ID от RioPay (UUID)."""
result = await db.execute(select(RioPayPayment).where(RioPayPayment.riopay_order_id == riopay_order_id))
return result.scalar_one_or_none()
async def get_riopay_payment_by_id(db: AsyncSession, payment_id: int) -> RioPayPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(RioPayPayment).where(RioPayPayment.id == payment_id))
return result.scalar_one_or_none()
async def update_riopay_payment_status(
db: AsyncSession,
payment: RioPayPayment,
*,
status: str,
is_paid: bool | None = None,
riopay_order_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> RioPayPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if riopay_order_id:
payment.riopay_order_id = riopay_order_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload:
payment.callback_payload = callback_payload
if transaction_id:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен статус платежа RioPay',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_riopay_payments(db: AsyncSession, user_id: int) -> list[RioPayPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(RioPayPayment).where(
RioPayPayment.user_id == user_id,
RioPayPayment.status == 'pending',
RioPayPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_riopay_payments(
db: AsyncSession,
) -> list[RioPayPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(RioPayPayment).where(
RioPayPayment.status == 'pending',
RioPayPayment.is_paid == False,
RioPayPayment.expires_at < now,
)
)
return list(result.scalars().all())
+193
View File
@@ -0,0 +1,193 @@
from datetime import UTC, datetime
import structlog
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SavedPaymentMethod
logger = structlog.get_logger(__name__)
async def create_saved_payment_method(
db: AsyncSession,
user_id: int,
yookassa_payment_method_id: str,
method_type: str = 'bank_card',
card_first6: str | None = None,
card_last4: str | None = None,
card_type: str | None = None,
card_expiry_month: str | None = None,
card_expiry_year: str | None = None,
title: str | None = None,
) -> SavedPaymentMethod | None:
"""Создаёт или реактивирует сохранённый метод оплаты."""
# Проверяем, есть ли уже такой метод (включая деактивированные)
result = await db.execute(
update(SavedPaymentMethod)
.where(
SavedPaymentMethod.yookassa_payment_method_id == yookassa_payment_method_id,
SavedPaymentMethod.user_id == user_id,
)
.values(
is_active=True,
method_type=method_type,
card_first6=card_first6,
card_last4=card_last4,
card_type=card_type,
card_expiry_month=card_expiry_month,
card_expiry_year=card_expiry_year,
title=title,
updated_at=datetime.now(UTC),
)
.returning(SavedPaymentMethod)
)
reactivated = result.scalar_one_or_none()
if reactivated:
await db.commit()
logger.info(
'Реактивирован сохранённый метод оплаты',
saved_method_id=reactivated.id,
user_id=user_id,
method_type=method_type,
card_last4=card_last4,
)
return reactivated
method = SavedPaymentMethod(
user_id=user_id,
yookassa_payment_method_id=yookassa_payment_method_id,
method_type=method_type,
card_first6=card_first6,
card_last4=card_last4,
card_type=card_type,
card_expiry_month=card_expiry_month,
card_expiry_year=card_expiry_year,
title=title,
)
db.add(method)
try:
await db.commit()
except IntegrityError as e:
await db.rollback()
logger.error(
'Ошибка создания сохранённого метода оплаты',
yookassa_payment_method_id=yookassa_payment_method_id,
user_id=user_id,
e=e,
)
return None
await db.refresh(method)
logger.info(
'Создан сохранённый метод оплаты',
saved_method_id=method.id,
user_id=user_id,
method_type=method_type,
card_last4=card_last4,
)
return method
async def get_active_payment_methods_by_user(
db: AsyncSession,
user_id: int,
) -> list[SavedPaymentMethod]:
"""Получить все активные сохранённые методы оплаты пользователя."""
result = await db.execute(
select(SavedPaymentMethod)
.where(
SavedPaymentMethod.user_id == user_id,
SavedPaymentMethod.is_active == True,
)
.order_by(SavedPaymentMethod.created_at.desc())
)
return list(result.scalars().all())
async def get_user_ids_with_active_payment_methods(
db: AsyncSession,
user_ids: list[int],
) -> set[int]:
"""Вернуть подмножество user_ids, у которых есть хотя бы один активный метод оплаты."""
if not user_ids:
return set()
result = await db.execute(
select(SavedPaymentMethod.user_id)
.where(
SavedPaymentMethod.user_id.in_(user_ids),
SavedPaymentMethod.is_active == True,
)
.distinct()
)
return set(result.scalars().all())
async def get_payment_method_by_yookassa_id(
db: AsyncSession,
yookassa_payment_method_id: str,
include_inactive: bool = False,
) -> SavedPaymentMethod | None:
"""Найти сохранённый метод по YooKassa payment_method.id."""
query = select(SavedPaymentMethod).where(
SavedPaymentMethod.yookassa_payment_method_id == yookassa_payment_method_id,
)
if not include_inactive:
query = query.where(SavedPaymentMethod.is_active == True)
result = await db.execute(query)
return result.scalar_one_or_none()
async def deactivate_payment_method(
db: AsyncSession,
saved_method_id: int,
user_id: int,
) -> bool:
"""Деактивировать (soft-delete) сохранённый метод оплаты."""
result = await db.execute(
update(SavedPaymentMethod)
.where(
SavedPaymentMethod.id == saved_method_id,
SavedPaymentMethod.user_id == user_id,
SavedPaymentMethod.is_active == True,
)
.values(is_active=False, updated_at=datetime.now(UTC))
)
await db.commit()
if result.rowcount > 0:
logger.info(
'Метод оплаты деактивирован',
saved_method_id=saved_method_id,
user_id=user_id,
)
return True
return False
async def deactivate_all_user_payment_methods(
db: AsyncSession,
user_id: int,
) -> int:
"""Деактивировать все методы оплаты пользователя. Возвращает количество деактивированных."""
result = await db.execute(
update(SavedPaymentMethod)
.where(
SavedPaymentMethod.user_id == user_id,
SavedPaymentMethod.is_active == True,
)
.values(is_active=False, updated_at=datetime.now(UTC))
)
await db.commit()
if result.rowcount > 0:
logger.info(
'Все методы оплаты пользователя деактивированы',
user_id=user_id,
count=result.rowcount,
)
return result.rowcount
+1 -215
View File
@@ -306,9 +306,8 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
await create_server_squad(
db=db,
squad_uuid=squad_uuid,
display_name=_generate_display_name(original_name),
display_name=original_name,
original_name=original_name,
country_code=_extract_country_code(original_name),
price_kopeks=1000,
is_available=False,
)
@@ -482,219 +481,6 @@ async def get_random_trial_squad_uuid(
return None
def _generate_display_name(original_name: str) -> str:
"""Генерирует отображаемое название сервера на основе оригинального имени."""
country_names = {
# Европа
'NL': '🇳🇱 Нидерланды',
'DE': '🇩🇪 Германия',
'FR': '🇫🇷 Франция',
'GB': '🇬🇧 Великобритания',
'UK': '🇬🇧 Великобритания',
'IT': '🇮🇹 Италия',
'ES': '🇪🇸 Испания',
'PT': '🇵🇹 Португалия',
'PL': '🇵🇱 Польша',
'CZ': '🇨🇿 Чехия',
'AT': '🇦🇹 Австрия',
'CH': '🇨🇭 Швейцария',
'SE': '🇸🇪 Швеция',
'NO': '🇳🇴 Норвегия',
'FI': '🇫🇮 Финляндия',
'DK': '🇩🇰 Дания',
'BE': '🇧🇪 Бельгия',
'IE': '🇮🇪 Ирландия',
'RO': '🇷🇴 Румыния',
'BG': '🇧🇬 Болгария',
'HU': '🇭🇺 Венгрия',
'GR': '🇬🇷 Греция',
'LV': '🇱🇻 Латвия',
'LT': '🇱🇹 Литва',
'EE': '🇪🇪 Эстония',
'SK': '🇸🇰 Словакия',
'SI': '🇸🇮 Словения',
'HR': '🇭🇷 Хорватия',
'RS': '🇷🇸 Сербия',
'UA': '🇺🇦 Украина',
'MD': '🇲🇩 Молдова',
'BY': '🇧🇾 Беларусь',
'LU': '🇱🇺 Люксембург',
# СНГ и Азия
'RU': '🇷🇺 Россия',
'KZ': '🇰🇿 Казахстан',
'UZ': '🇺🇿 Узбекистан',
'GE': '🇬🇪 Грузия',
'AM': '🇦🇲 Армения',
'AZ': '🇦🇿 Азербайджан',
# Америка
'US': '🇺🇸 США',
'CA': '🇨🇦 Канада',
'MX': '🇲🇽 Мексика',
'BR': '🇧🇷 Бразилия',
'AR': '🇦🇷 Аргентина',
'CL': '🇨🇱 Чили',
'CO': '🇨🇴 Колумбия',
# Азия
'JP': '🇯🇵 Япония',
'KR': '🇰🇷 Южная Корея',
'CN': '🇨🇳 Китай',
'HK': '🇭🇰 Гонконг',
'TW': '🇹🇼 Тайвань',
'SG': '🇸🇬 Сингапур',
'TH': '🇹🇭 Таиланд',
'VN': '🇻🇳 Вьетнам',
'MY': '🇲🇾 Малайзия',
'ID': '🇮🇩 Индонезия',
'PH': '🇵🇭 Филиппины',
'IN': '🇮🇳 Индия',
'PK': '🇵🇰 Пакистан',
# Ближний Восток
'IL': '🇮🇱 Израиль',
'TR': '🇹🇷 Турция',
'AE': '🇦🇪 ОАЭ',
'SA': '🇸🇦 Саудовская Аравия',
'QA': '🇶🇦 Катар',
'BH': '🇧🇭 Бахрейн',
'KW': '🇰🇼 Кувейт',
# Океания
'AU': '🇦🇺 Австралия',
'NZ': '🇳🇿 Новая Зеландия',
# Африка
'ZA': '🇿🇦 ЮАР',
'EG': '🇪🇬 Египет',
'NG': '🇳🇬 Нигерия',
'KE': '🇰🇪 Кения',
}
name_upper = original_name.upper()
# Сначала ищем код как отдельный элемент (через - или _)
for code, display_name in country_names.items():
if f'-{code}' in name_upper or f'_{code}' in name_upper:
return display_name
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
return display_name
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
return display_name
if name_upper == code:
return display_name
# Потом ищем просто вхождение кода
for code, display_name in country_names.items():
if code in name_upper:
return display_name
return f'🌍 {original_name}'
def _extract_country_code(original_name: str) -> str | None:
"""Извлекает код страны из оригинального названия."""
# Полный список кодов стран
codes = [
# Европа
'NL',
'DE',
'FR',
'GB',
'UK',
'IT',
'ES',
'PT',
'PL',
'CZ',
'AT',
'CH',
'SE',
'NO',
'FI',
'DK',
'BE',
'IE',
'RO',
'BG',
'HU',
'GR',
'LV',
'LT',
'EE',
'SK',
'SI',
'HR',
'RS',
'UA',
'MD',
'BY',
'LU',
# СНГ
'RU',
'KZ',
'UZ',
'GE',
'AM',
'AZ',
# Америка
'US',
'CA',
'MX',
'BR',
'AR',
'CL',
'CO',
# Азия
'JP',
'KR',
'CN',
'HK',
'TW',
'SG',
'TH',
'VN',
'MY',
'ID',
'PH',
'IN',
'PK',
# Ближний Восток
'IL',
'TR',
'AE',
'SA',
'QA',
'BH',
'KW',
# Океания
'AU',
'NZ',
# Африка
'ZA',
'EG',
'NG',
'KE',
]
name_upper = original_name.upper()
# Сначала ищем код как отдельный элемент
for code in codes:
if f'-{code}' in name_upper or f'_{code}' in name_upper:
return code
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
return code
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
return code
if name_upper == code:
return code
# Потом просто ищем вхождение
for code in codes:
if code in name_upper:
return code
return None
async def get_server_statistics(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(ServerSquad.id)))
total_servers = total_result.scalar()
+48 -27
View File
@@ -21,7 +21,7 @@ from app.database.models import (
UserPromoGroup,
UserStatus,
)
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
from app.utils.pricing_utils import calculate_months_from_days
from app.utils.timezone import format_local_datetime
@@ -190,6 +190,7 @@ async def create_paid_subscription(
update_server_counters: bool = False,
is_trial: bool = False,
tariff_id: int | None = None,
commit: bool = True,
) -> Subscription:
end_date = datetime.now(UTC) + timedelta(days=duration_days)
@@ -211,8 +212,11 @@ async def create_paid_subscription(
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
if commit:
await db.commit()
await db.refresh(subscription)
else:
await db.flush()
logger.info(
'💎 Создана платная подписка для пользователя ID: статус',
@@ -265,6 +269,7 @@ async def replace_subscription(
autopay_enabled: bool | None = None,
autopay_days_before: int | None = None,
update_server_counters: bool = False,
commit: bool = True,
) -> Subscription:
"""Перезаписывает параметры существующей подписки пользователя."""
@@ -297,12 +302,15 @@ async def replace_subscription(
subscription.autopay_days_before = new_autopay_days_before
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription)
if commit:
await db.commit()
await db.refresh(subscription)
else:
await db.flush()
# Очищаем старые записи об отправленных уведомлениях при замене подписки
# (аналогично extend_subscription), чтобы новые уведомления отправлялись корректно
await clear_notifications(db, subscription.id)
await clear_notifications(db, subscription.id, commit=commit)
if update_server_counters:
try:
@@ -764,26 +772,35 @@ async def deactivate_subscription(db: AsyncSession, subscription: Subscription)
async def reactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
"""Реактивация подписки (например, после повторной подписки на канал).
"""Реактивация подписки (например, после повторной подписки на канал или докупки трафика).
Активирует только если подписка была DISABLED и ещё не истекла.
Активирует если подписка была DISABLED или EXPIRED и ещё не истекла по времени.
Не логирует если реактивация не требуется.
"""
now = datetime.now(UTC)
# Тихо выходим если реактивация не нужна
if subscription.status != SubscriptionStatus.DISABLED.value:
# Тихо выходим если реактивация не нужна (уже активна или другой статус)
reactivatable_statuses = {SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value}
if subscription.status not in reactivatable_statuses:
return subscription
if subscription.end_date and subscription.end_date <= now:
if not subscription.end_date or subscription.end_date <= now:
return subscription
old_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = now
await db.commit()
await db.refresh(subscription)
logger.info(
'✅ Подписка реактивирована',
subscription_id=subscription.id,
user_id=subscription.user_id,
old_status=old_status,
)
return subscription
@@ -1114,7 +1131,8 @@ async def add_subscription_servers(
await db.refresh(subscription)
if paid_prices is None:
months_remaining = get_remaining_months(subscription.end_date)
now = datetime.now(UTC)
days_remaining = max(1, (subscription.end_date - now).days)
paid_prices = []
from app.database.models import ServerSquad
@@ -1122,7 +1140,7 @@ async def add_subscription_servers(
for server_id in server_squad_ids:
result = await db.execute(select(ServerSquad.price_kopeks).where(ServerSquad.id == server_id))
server_price_per_month = result.scalar() or 0
total_price_for_period = server_price_per_month * months_remaining
total_price_for_period = int(server_price_per_month * days_remaining / 30)
paid_prices.append(total_price_for_period)
for i, server_id in enumerate(server_squad_ids):
@@ -1556,8 +1574,9 @@ async def calculate_addon_cost_for_remaining_period(
if additional_server_ids is None:
additional_server_ids = []
months_to_pay = get_remaining_months(subscription.end_date)
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
now = datetime.now(UTC)
days_to_pay = max(1, (subscription.end_date - now).days)
period_hint_days = days_to_pay
total_cost = 0
@@ -1575,11 +1594,13 @@ async def calculate_addon_cost_for_remaining_period(
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
traffic_total_cost = discounted_traffic_per_month * months_to_pay
traffic_total_cost = int(discounted_traffic_per_month * days_to_pay / 30)
total_cost += traffic_total_cost
message = f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес × {months_to_pay} = {traffic_total_cost / 100}'
message = f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес × {days_to_pay} дн. = {traffic_total_cost / 100}'
if traffic_discount_per_month > 0:
message += f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay / 100}₽)'
message += (
f' (скидка {traffic_discount_percent}%: -{int(traffic_discount_per_month * days_to_pay / 30) / 100}₽)'
)
logger.info(message)
if additional_devices > 0:
@@ -1592,11 +1613,13 @@ async def calculate_addon_cost_for_remaining_period(
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
devices_total_cost = discounted_devices_per_month * months_to_pay
devices_total_cost = int(discounted_devices_per_month * days_to_pay / 30)
total_cost += devices_total_cost
message = f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес × {months_to_pay} = {devices_total_cost / 100}'
message = f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес × {days_to_pay} дн. = {devices_total_cost / 100}'
if devices_discount_per_month > 0:
message += f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay / 100}₽)'
message += (
f' (скидка {devices_discount_percent}%: -{int(devices_discount_per_month * days_to_pay / 30) / 100}₽)'
)
logger.info(message)
if additional_server_ids:
@@ -1617,16 +1640,14 @@ async def calculate_addon_cost_for_remaining_period(
)
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
discounted_server_per_month = server_price_per_month - server_discount_per_month
server_total_cost = discounted_server_per_month * months_to_pay
server_total_cost = int(discounted_server_per_month * days_to_pay / 30)
total_cost += server_total_cost
message = f'Сервер {server_name}: {server_price_per_month / 100}₽/мес × {months_to_pay} = {server_total_cost / 100}'
message = f'Сервер {server_name}: {server_price_per_month / 100}₽/мес × {days_to_pay} дн. = {server_total_cost / 100}'
if server_discount_per_month > 0:
message += (
f' (скидка {servers_discount_percent}%: -{server_discount_per_month * months_to_pay / 100}₽)'
)
message += f' (скидка {servers_discount_percent}%: -{int(server_discount_per_month * days_to_pay / 30) / 100}₽)'
logger.info(message)
logger.info('💰 Итого доплата за мес: ₽', months_to_pay=months_to_pay, total_cost=total_cost / 100)
logger.info('💰 Итого доплата за дн.: ₽', days_to_pay=days_to_pay, total_cost=total_cost / 100)
return total_cost
+9
View File
@@ -185,6 +185,8 @@ async def create_tariff(
traffic_price_per_gb_kopeks: int = 0,
min_traffic_gb: int = 1,
max_traffic_gb: int = 1000,
# Видимость в разделе подарков
show_in_gift: bool = True,
# Режим сброса трафика
traffic_reset_mode: str | None = None, # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
@@ -223,6 +225,8 @@ async def create_tariff(
traffic_price_per_gb_kopeks=max(0, traffic_price_per_gb_kopeks),
min_traffic_gb=max(1, min_traffic_gb),
max_traffic_gb=max(1, max_traffic_gb),
# Видимость в разделе подарков
show_in_gift=show_in_gift,
# Режим сброса трафика
traffic_reset_mode=traffic_reset_mode,
# Внешний сквад
@@ -290,6 +294,8 @@ async def update_tariff(
traffic_price_per_gb_kopeks: int | None = None,
min_traffic_gb: int | None = None,
max_traffic_gb: int | None = None,
# Видимость в разделе подарков
show_in_gift: bool | None = None,
# Режим сброса трафика
traffic_reset_mode: str | None = ..., # ... = не передан, None = сбросить к глобальной настройке
# Внешний сквад RemnaWave
@@ -354,6 +360,9 @@ async def update_tariff(
tariff.min_traffic_gb = max(1, min_traffic_gb)
if max_traffic_gb is not None:
tariff.max_traffic_gb = max(1, max_traffic_gb)
# Видимость в разделе подарков
if show_in_gift is not None:
tariff.show_in_gift = show_in_gift
# Режим сброса трафика
if traffic_reset_mode is not ...:
tariff.traffic_reset_mode = traffic_reset_mode
+2 -1
View File
@@ -498,6 +498,7 @@ async def subtract_user_balance(
create_transaction: bool = False,
payment_method: PaymentMethod | None = None,
*,
transaction_type: TransactionType = TransactionType.WITHDRAWAL,
consume_promo_offer: bool = False,
mark_as_paid_subscription: bool = False,
) -> bool:
@@ -576,7 +577,7 @@ async def subtract_user_balance(
await create_trans(
db=db,
user_id=user.id,
type=TransactionType.WITHDRAWAL,
type=transaction_type,
amount_kopeks=amount_kopeks,
description=description,
payment_method=payment_method,
+2 -1
View File
@@ -40,6 +40,7 @@ async def _sync_user_primary_promo_group(
except Exception as error:
logger.error('Ошибка синхронизации primary промогруппы пользователя', user_id=user_id, error=error)
raise
async def sync_user_primary_promo_group(
@@ -187,7 +188,7 @@ async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoG
return None
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None
return user_promo_groups[0].promo_group or None
except Exception as error:
logger.error('Ошибка получения primary промогруппы пользователя', user_id=user_id, error=error)
+107 -7
View File
@@ -8,7 +8,7 @@ def _aware(dt: datetime | None) -> datetime | None:
return dt
from enum import Enum
from enum import Enum, StrEnum
from sqlalchemy import (
JSON,
@@ -157,6 +157,7 @@ class PaymentMethod(Enum):
CLOUDPAYMENTS = 'cloudpayments'
FREEKASSA = 'freekassa'
KASSA_AI = 'kassa_ai'
RIOPAY = 'riopay'
MANUAL = 'manual'
BALANCE = 'balance'
@@ -238,6 +239,38 @@ class YooKassaPayment(Base):
return f'<YooKassaPayment(id={self.id}, yookassa_id={self.yookassa_payment_id}, amount={self.amount_rubles}₽, status={self.status})>'
class SavedPaymentMethod(Base):
__tablename__ = 'saved_payment_methods'
__table_args__ = (Index('ix_saved_payment_methods_user_active', 'user_id', 'is_active'),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
# YooKassa payment_method.id — ключ для рекуррентных списаний
yookassa_payment_method_id = Column(String(255), unique=True, nullable=False, index=True)
# Тип метода: bank_card, yoo_money, sberbank, tinkoff_bank, sbp, mir_pay
method_type = Column(String(50), nullable=False, default='bank_card')
# Отображаемые данные карты (маскированные)
card_first6 = Column(String(6), nullable=True)
card_last4 = Column(String(4), nullable=True)
card_type = Column(String(50), nullable=True) # Visa, MasterCard, Mir
card_expiry_month = Column(String(2), nullable=True)
card_expiry_year = Column(String(4), nullable=True)
title = Column(String(255), nullable=True) # "Bank card *4444"
is_active = Column(Boolean, default=True)
created_at = Column(AwareDateTime(), nullable=False, server_default=func.now())
updated_at = Column(AwareDateTime(), nullable=False, server_default=func.now(), onupdate=func.now())
user = relationship('User', backref='saved_payment_methods')
def __repr__(self):
return f'<SavedPaymentMethod(id={self.id}, user_id={self.user_id}, type={self.method_type}, last4={self.card_last4})>'
class CryptoBotPayment(Base):
__tablename__ = 'cryptobot_payments'
@@ -716,6 +749,68 @@ class KassaAiPayment(Base):
return f'<KassaAiPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class RioPayPayment(Base):
"""Платежи через RioPay (api.riopay.online)."""
__tablename__ = 'riopay_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
riopay_order_id = Column(String(64), unique=True, nullable=True, index=True) # UUID от RioPay
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending') # pending, success, failed, expired, canceled
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_method = Column(String(32), nullable=True) # CARD, SBP
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='riopay_payments')
transaction = relationship('Transaction', backref='riopay_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == 'pending'
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'canceled']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<RioPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class PromoGroup(Base):
__tablename__ = 'promo_groups'
@@ -879,6 +974,9 @@ class Tariff(Base):
min_traffic_gb = Column(Integer, default=1, nullable=False) # Минимальный трафик в ГБ
max_traffic_gb = Column(Integer, default=1000, nullable=False) # Максимальный трафик в ГБ
# Видимость в разделе подарков
show_in_gift = Column(Boolean, default=True, server_default='true', nullable=False)
# Режим сброса трафика: DAY, WEEK, MONTH, NO_RESET (по умолчанию берётся из конфига)
traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку
@@ -1049,7 +1147,9 @@ class User(Base):
discord_id = Column(String(255), unique=True, nullable=True, index=True)
vk_id = Column(BigInteger, unique=True, nullable=True, index=True)
broadcasts = relationship('BroadcastHistory', back_populates='admin')
referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id')
referrals = relationship(
'User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id', post_update=True
)
subscription = relationship('Subscription', back_populates='user', uselist=False)
transactions = relationship('Transaction', back_populates='user')
referral_earnings = relationship('ReferralEarning', foreign_keys='ReferralEarning.user_id', back_populates='user')
@@ -1124,10 +1224,10 @@ class User(Base):
def get_primary_promo_group(self):
"""Возвращает промогруппу с максимальным приоритетом."""
if not self.user_promo_groups:
return getattr(self, 'promo_group', None)
try:
if not self.user_promo_groups:
return getattr(self, 'promo_group', None)
# Сортируем по приоритету группы (убывание), затем по ID группы
# Используем getattr для защиты от ленивой загрузки
sorted_groups = sorted(
@@ -1139,7 +1239,7 @@ class User(Base):
if sorted_groups and sorted_groups[0].promo_group:
return sorted_groups[0].promo_group
except Exception:
# Если возникла ошибка (например, ленивая загрузка), fallback на старую связь
# Если возникла ошибка (например, ленивая загрузка в async), fallback на старую связь
pass
# Fallback на старую связь если новая пустая или возникла ошибка
@@ -3061,7 +3161,7 @@ class LandingPage(Base):
return f"<LandingPage slug='{self.slug}' active={self.is_active}>"
class GuestPurchaseStatus(str, Enum):
class GuestPurchaseStatus(StrEnum):
PENDING = 'pending'
PAID = 'paid'
DELIVERED = 'delivered'
+1 -1
View File
@@ -29,7 +29,7 @@ async def show_blacklist_settings(callback: types.CallbackQuery, db_user: User,
blacklist_count = len(await blacklist_service.get_all_blacklisted_users())
status_text = '✅ Включена' if is_enabled else '❌ Отключена'
url_text = github_url if github_url else 'Не задан'
url_text = github_url or 'Не задан'
text = f"""
🔐 <b>Настройки черного списка</b>
+48 -1
View File
@@ -63,7 +63,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
},
'payments': {
'title': '💳 Платежные системы',
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI и Telegram Stars.',
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay и Telegram Stars.',
'icon': '💳',
'categories': (
'PAYMENT',
@@ -74,6 +74,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
'CLOUDPAYMENTS',
'FREEKASSA',
'KASSA_AI',
'RIOPAY',
'MULENPAY',
'PAL24',
'WATA',
@@ -263,6 +264,7 @@ def _get_group_status(group_key: str) -> tuple[str, str]:
'CloudPayments': settings.is_cloudpayments_enabled(),
'Freekassa': settings.is_freekassa_enabled(),
'Kassa AI': settings.is_kassa_ai_enabled(),
'RioPay': settings.is_riopay_enabled(),
'MulenPay': settings.is_mulenpay_enabled(),
'PAL24': settings.is_pal24_enabled(),
'Tribute': settings.TRIBUTE_ENABLED,
@@ -1251,6 +1253,9 @@ def _build_settings_keyboard(
elif category_key == 'KASSA_AI':
label = texts.t('PAYMENT_KASSA_AI', f'💳 {settings.get_kassa_ai_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'kassa_ai')])
elif category_key == 'RIOPAY':
label = texts.t('PAYMENT_RIOPAY', f'💳 {settings.get_riopay_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'riopay')])
if test_payment_buttons:
rows.extend(test_payment_buttons)
@@ -2325,6 +2330,48 @@ async def test_payment_provider(
await _refresh_markup()
return
if method == 'riopay':
if not settings.is_riopay_enabled():
await callback.answer('❌ RioPay отключена', show_alert=True)
return
amount_kopeks = settings.RIOPAY_MIN_AMOUNT_KOPEKS
payment_result = await payment_service.create_riopay_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description='Тестовый платеж RioPay (админ)',
email=getattr(db_user, 'email', None),
language=db_user.language or settings.DEFAULT_LANGUAGE,
)
if not payment_result or not payment_result.get('payment_url'):
await callback.answer('❌ Не удалось создать тестовый платеж RioPay', show_alert=True)
await _refresh_markup()
return
payment_url = payment_result['payment_url']
display_name = settings.get_riopay_display_name()
message_text = (
f'🧪 <b>Тестовый платеж {display_name}</b>\n\n'
f'💰 Сумма: {texts.format_price(amount_kopeks)}\n'
f'🆔 Order ID: {payment_result["order_id"]}'
)
reply_markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text='💳 Перейти к оплате',
url=payment_url,
)
]
]
)
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML')
await callback.answer(f'✅ Ссылка на платеж {display_name} отправлена', show_alert=True)
await _refresh_markup()
return
await callback.answer('❌ Неизвестный способ тестирования платежа', show_alert=True)
await _refresh_markup()
+4 -4
View File
@@ -1350,12 +1350,12 @@ async def _do_reconcile_logs(callback: CallbackQuery):
await callback.answer('🔄 Анализирую логи платежей...', show_alert=False)
# Путь к файлу логов платежей (logs/current/)
log_file_path = Path(settings.LOG_FILE).resolve()
log_file_path = await asyncio.to_thread(Path(settings.LOG_FILE).resolve)
log_dir = log_file_path.parent
current_dir = log_dir / 'current'
payments_log = current_dir / settings.LOG_PAYMENTS_FILE
if not payments_log.exists():
if not await asyncio.to_thread(payments_log.exists):
try:
await callback.message.edit_text(
'❌ <b>Файл логов не найден</b>\n\n'
@@ -1491,12 +1491,12 @@ async def receipts_reconcile_logs_details_callback(callback: CallbackQuery):
await callback.answer('🔄 Загружаю детали...', show_alert=False)
# Путь к логам (logs/current/)
log_file_path = Path(settings.LOG_FILE).resolve()
log_file_path = await asyncio.to_thread(Path(settings.LOG_FILE).resolve)
log_dir = log_file_path.parent
current_dir = log_dir / 'current'
payments_log = current_dir / settings.LOG_PAYMENTS_FILE
if not payments_log.exists():
if not await asyncio.to_thread(payments_log.exists):
await callback.answer('❌ Файл логов не найден', show_alert=True)
return
+4 -2
View File
@@ -48,6 +48,8 @@ def _method_display(method: PaymentMethod) -> str:
return 'Telegram Stars'
if method == PaymentMethod.KASSA_AI:
return settings.get_kassa_ai_display_name()
if method == PaymentMethod.RIOPAY:
return settings.get_riopay_display_name()
if method == PaymentMethod.FREEKASSA:
return settings.get_freekassa_display_name()
return method.value
@@ -186,7 +188,7 @@ def _is_checkable(record: PendingPayment) -> bool:
if record.method == PaymentMethod.YOOKASSA:
return status in {'pending', 'waiting_for_capture'}
if record.method == PaymentMethod.CRYPTOBOT:
return status in {'active'}
return status == 'active'
if record.method == PaymentMethod.FREEKASSA:
return status in {'pending', 'created', ''}
if record.method == PaymentMethod.KASSA_AI:
@@ -378,7 +380,7 @@ def _build_payment_details_text(record: PendingPayment, *, texts, language: str)
amount = f'{crypto_amount} {crypto_asset}'
created = format_datetime(record.created_at)
age = format_time_ago(record.created_at, language)
raw_identifier = record.identifier if record.identifier else record.local_id
raw_identifier = record.identifier or record.local_id
identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else ''
lines = [
texts.t('ADMIN_PAYMENT_DETAILS_TITLE', '💳 <b>Payment details</b>'),
+1 -1
View File
@@ -977,7 +977,7 @@ async def _render_squad_selection(
if not selected_server:
selected_server = await get_server_squad_by_uuid(db, selected_uuid)
if selected_server:
selected_server_name = selected_server.display_name
selected_server_name = html.escape(selected_server.display_name)
header = texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE', '🌍 <b>Выберите сквад</b>')
if selected_server_name:
+5 -4
View File
@@ -1,3 +1,4 @@
import asyncio
import json
from datetime import UTC, datetime, timedelta
@@ -756,8 +757,8 @@ async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncS
# Информация о логах
log_path = referral_diagnostics_service.log_path
log_exists = log_path.exists()
log_size = log_path.stat().st_size if log_exists else 0
log_exists = await asyncio.to_thread(log_path.exists)
log_size = (await asyncio.to_thread(log_path.stat)).st_size if log_exists else 0
text += f'\n<i>📂 {log_path.name}'
if log_exists:
@@ -1434,9 +1435,9 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
finally:
# Удаляем временный файл
if temp_file_path and Path(temp_file_path).exists():
if temp_file_path and await asyncio.to_thread(Path(temp_file_path).exists):
try:
Path(temp_file_path).unlink()
await asyncio.to_thread(Path(temp_file_path).unlink)
logger.info('🗑️ Временный файл удалён', temp_file_path=temp_file_path)
except Exception as e:
logger.error('Ошибка удаления временного файла', error=e)
+2 -1
View File
@@ -1,3 +1,4 @@
import html
import math
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -175,7 +176,7 @@ def _format_migration_server_label(texts, server) -> str:
return texts.t(
'ADMIN_SQUAD_MIGRATION_SERVER_LABEL',
'{name} — 👥 {users} ({status})',
).format(name=server.display_name, users=server.current_users, status=status)
).format(name=html.escape(server.display_name), users=server.current_users, status=status)
def _build_migration_keyboard(
+9 -9
View File
@@ -44,8 +44,8 @@ def _build_server_edit_view(server):
<b>Информация:</b>
ID: {server.id}
UUID: <code>{server.squad_uuid}</code>
Название: {server.display_name}
Оригинальное: {server.original_name or 'Не указано'}
Название: {html.escape(server.display_name)}
Оригинальное: {html.escape(server.original_name) if server.original_name else 'Не указано'}
Статус: {status_emoji}
<b>Настройки:</b>
@@ -172,7 +172,7 @@ async def show_servers_list(callback: types.CallbackQuery, db_user: User, db: As
status_emoji = '' if server.is_available else ''
price_text = f'{int(server.price_rubles)}' if server.price_kopeks > 0 else 'Бесплатно'
text += f'{i}. {status_emoji} {server.display_name}\n'
text += f'{i}. {status_emoji} {html.escape(server.display_name)}\n'
text += f' 💰 Цена: {price_text}'
if server.max_users:
@@ -559,7 +559,7 @@ async def start_server_edit_name(callback: types.CallbackQuery, state: FSMContex
await callback.message.edit_text(
f'✏️ <b>Редактирование названия</b>\n\n'
f'Текущее название: <b>{server.display_name}</b>\n\n'
f'Текущее название: <b>{html.escape(server.display_name)}</b>\n\n'
f'Отправьте новое название для сервера:',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
@@ -621,7 +621,7 @@ async def delete_server_confirm(callback: types.CallbackQuery, db_user: User, db
🗑 <b>Удаление сервера</b>
Вы действительно хотите удалить сервер:
<b>{server.display_name}</b>
<b>{html.escape(server.display_name)}</b>
<b>Внимание!</b>
Сервер можно удалить только если к нему нет активных подключений.
@@ -658,7 +658,7 @@ async def delete_server_execute(callback: types.CallbackQuery, db_user: User, db
await cache.delete_pattern('available_countries*')
await callback.message.edit_text(
f'✅ Сервер <b>{server.display_name}</b> успешно удален!',
f'✅ Сервер <b>{html.escape(server.display_name)}</b> успешно удален!',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='📋 К списку серверов', callback_data='admin_servers_list')]
@@ -668,7 +668,7 @@ async def delete_server_execute(callback: types.CallbackQuery, db_user: User, db
)
else:
await callback.message.edit_text(
f'❌ Не удалось удалить сервер <b>{server.display_name}</b>\n\nВозможно, к нему есть активные подключения.',
f'❌ Не удалось удалить сервер <b>{html.escape(server.display_name)}</b>\n\nВозможно, к нему есть активные подключения.',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='🔙 К серверу', callback_data=f'admin_server_edit_{server_id}')]
@@ -706,7 +706,7 @@ async def show_server_detailed_stats(callback: types.CallbackQuery, db_user: Use
for i, server in enumerate(sorted_servers[:5], 1):
price_text = f'{int(server.price_rubles)}' if server.price_kopeks > 0 else 'Бесплатно'
text += f'{i}. {server.display_name} - {price_text}\n'
text += f'{i}. {html.escape(server.display_name)} - {price_text}\n'
if not sorted_servers:
text += 'Нет доступных серверов\n'
@@ -968,7 +968,7 @@ async def start_server_edit_promo_groups(
text = (
'🎯 <b>Настройка промогрупп</b>\n\n'
f'Сервер: <b>{server.display_name}</b>\n\n'
f'Сервер: <b>{html.escape(server.display_name)}</b>\n\n'
'Выберите промогруппы, которым будет доступен этот сервер.\n'
'Должна быть выбрана минимум одна промогруппа.'
)
+1 -1
View File
@@ -81,7 +81,7 @@ def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int
if current.strip():
pages.append(current)
return pages if pages else [header]
return pages or [header]
async def show_admin_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
+41 -5
View File
@@ -1,3 +1,4 @@
import html
import re
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
@@ -868,7 +869,7 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
try:
server = await get_server_squad_by_uuid(db, squad_uuid)
if server:
text += f'{server.display_name}\n'
text += f'{html.escape(server.display_name)}\n'
else:
text += f'{squad_uuid[:8]}... (неизвестный)\n'
except Exception as e:
@@ -4002,12 +4003,20 @@ async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, adm
else:
await add_subscription_traffic(db, subscription, gb)
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
await reactivate_subscription(db, subscription)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if subscription.status == 'active':
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if user and user.remnawave_uuid:
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
traffic_text = 'безлимитный' if gb == 0 else f'{gb} ГБ'
logger.info('Админ добавил трафик пользователю', admin_id=admin_id, traffic_text=traffic_text, user_id=user_id)
return True
@@ -5309,9 +5318,24 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
try:
old_tariff_id = subscription.tariff_id
# Обновляем параметры подписки в соответствии с тарифом
# Preserve extra purchased devices above the old tariff's base limit
extra_devices = 0
if subscription.tariff_id:
old_tariff = await get_tariff_by_id(db, subscription.tariff_id)
if old_tariff and old_tariff.device_limit:
extra_devices = max(0, (subscription.device_limit or old_tariff.device_limit) - old_tariff.device_limit)
subscription.tariff_id = tariff.id
subscription.device_limit = tariff.device_limit
new_base = tariff.device_limit or 1
new_total = new_base + extra_devices
effective_max = tariff.max_device_limit or (
settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
)
if effective_max and new_total > effective_max:
new_total = effective_max
subscription.device_limit = new_total
subscription.traffic_limit_gb = tariff.traffic_limit_gb
subscription.connected_squads = tariff.allowed_squads or []
subscription.updated_at = datetime.now(UTC)
@@ -5329,6 +5353,18 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
subscription.traffic_used_gb = 0.0
# Записываем транзакцию о смене тарифа
from app.database.crud.transaction import create_transaction
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=0,
description=f"Смена тарифа администратором на '{tariff.name}'",
commit=False,
)
await db.commit()
# Синхронизируем с RemnaWave (сброс трафика по админ-настройке)
@@ -5352,7 +5388,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
await callback.message.edit_text(
f'✅ <b>Тариф успешно изменен</b>\n\n'
f'Новый тариф: <b>{tariff.name}</b>\n'
f'• Устройства: {tariff.device_limit}\n'
f'• Устройства: {subscription.device_limit}\n'
f'• Трафик: {"♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"}\n'
f'• Серверы: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}',
reply_markup=types.InlineKeyboardMarkup(
+2 -2
View File
@@ -21,8 +21,8 @@ logger = structlog.get_logger(__name__)
FREEKASSA_SUB_METHODS = {
'freekassa_sbp': {'payment_system_id': 44, 'get_name': lambda: settings.get_freekassa_sbp_display_name()},
'freekassa_card': {'payment_system_id': 36, 'get_name': lambda: settings.get_freekassa_card_display_name()},
'freekassa_sbp': {'payment_system_id': 44, 'get_name': settings.get_freekassa_sbp_display_name},
'freekassa_card': {'payment_system_id': 36, 'get_name': settings.get_freekassa_card_display_name},
}
+13 -136
View File
@@ -138,6 +138,13 @@ async def route_payment_by_method(
await process_kassa_ai_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'riopay':
from .riopay import process_riopay_payment_amount
async with AsyncSessionLocal() as db:
await process_riopay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
return False
@@ -397,10 +404,7 @@ async def handle_balance_history_pagination(callback: types.CallbackQuery, db_us
@error_handler
async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
from app.config import settings
from app.database.crud.subscription import get_subscription_by_user_id
from app.services.subscription_service import SubscriptionService
from app.utils.payment_utils import get_payment_methods_text
from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days
texts = get_texts(db_user.language)
@@ -423,139 +427,7 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
payment_text = get_payment_methods_text(db_user.language)
# Добавляем информацию о текущем тарифе пользователя
subscription = await get_subscription_by_user_id(db, db_user.id)
tariff_info = ''
if subscription and not subscription.is_trial:
# Рассчитываем приблизительную стоимость продления на 30 дней
duration_days = 30 # Берем для примера 30 дней
current_traffic = subscription.traffic_limit_gb
current_connected_squads = subscription.connected_squads or []
current_device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
try:
# Получаем цены для текущих параметров
from app.config import PERIOD_PRICES
from app.database.crud.tariff import get_tariff_by_id
# В режиме тарифов берём цену из тарифа пользователя
tariff = None
tariff_price_found = False
base_price_original = 0
if settings.is_tariffs_mode() and subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.period_prices:
base_price_original = tariff.period_prices.get(str(duration_days), 0)
if base_price_original > 0:
tariff_price_found = True
# Если не нашли в тарифе - используем PERIOD_PRICES
if base_price_original <= 0:
base_price_original = PERIOD_PRICES.get(duration_days, 0)
if tariff_price_found:
# Тарифный режим: серверы и трафик включены в цену тарифа.
# Порядок: база + устройства → скидка на полную сумму (как в calculate_renewal_price).
from app.utils.promo_offer import get_user_active_promo_discount_percent
original_price = base_price_original
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
device_limit = (
subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
)
extra_devices = max(0, device_limit - tariff_device_limit)
device_price_per_unit = (
tariff.device_price_kopeks
if tariff and tariff.device_price_kopeks is not None
else settings.PRICE_PER_DEVICE
)
months_in_period = calculate_months_from_days(duration_days)
devices_price = extra_devices * device_price_per_unit * months_in_period
original_price += devices_price
# Скидка промогруппы на полную сумму (база + устройства)
period_discount_percent = db_user.get_promo_discount('period', duration_days)
discount_total = original_price * period_discount_percent // 100
total_price = original_price - discount_total
# Promo-offer скидка (временная)
promo_offer_percent = get_user_active_promo_discount_percent(db_user)
if promo_offer_percent > 0:
promo_offer_discount = total_price * promo_offer_percent // 100
total_price = total_price - promo_offer_discount
else:
# Классический режим: серверы + трафик + устройства считаются отдельно
period_discount_percent = db_user.get_promo_discount('period', duration_days)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
period_discount_percent,
)
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
(
servers_price_per_month,
per_server_monthly_prices,
) = await subscription_service.get_countries_price_by_uuids(
current_connected_squads,
db,
promo_group_id=db_user.promo_group_id,
)
servers_discount_percent = db_user.get_promo_discount('servers', duration_days)
total_servers_price = 0
for server_price in per_server_monthly_prices:
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price,
servers_discount_percent,
)
total_servers_price += discounted_per_month
traffic_price_per_month = settings.get_traffic_price(current_traffic)
traffic_discount_percent = db_user.get_promo_discount('traffic', duration_days)
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
traffic_price_per_month,
traffic_discount_percent,
)
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount('devices', duration_days)
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
months_in_period = calculate_months_from_days(duration_days)
total_price = (
base_price
+ total_servers_price * months_in_period
+ traffic_discounted_per_month * months_in_period
+ devices_discounted_per_month * months_in_period
)
traffic_value = current_traffic or 0
if traffic_value <= 0:
traffic_display = texts.t('TRAFFIC_UNLIMITED_SHORT', 'Безлимит')
else:
traffic_display = texts.format_traffic(traffic_value)
current_tariff_desc = (
f'📱 Подписка: {len(current_connected_squads)} серверов, '
f'{traffic_display}, {current_device_limit} устр.'
)
estimated_price_info = (
f'💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней'
)
tariff_info = f'\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}'
except Exception as e:
logger.warning(
'Не удалось рассчитать стоимость текущей подписки для пользователя', db_user_id=db_user.id, error=e
)
tariff_info = ''
full_text = payment_text + tariff_info
full_text = payment_text
keyboard = get_payment_methods_keyboard(0, db_user.language)
@@ -980,6 +852,11 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_kassa_ai_topup, F.data == 'topup_kassa_ai')
dp.callback_query.register(process_kassa_ai_quick_amount, F.data.startswith('topup_amount|kassa_ai|'))
from .riopay import process_riopay_quick_amount, start_riopay_topup
dp.callback_query.register(start_riopay_topup, F.data == 'topup_riopay')
dp.callback_query.register(process_riopay_quick_amount, F.data.startswith('topup_amount|riopay|'))
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
+354
View File
@@ -0,0 +1,354 @@
"""Handler for RioPay balance top-up."""
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _create_riopay_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
):
"""
Common logic for creating RioPay payment and sending response.
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_riopay_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(
error_text,
parse_mode='HTML',
)
return
payment_url = result.get('payment_url')
display_name = settings.get_riopay_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'💳 Оплатить {amount}',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'RIOPAY_PAYMENT_CREATED',
'💳 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info('RioPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_riopay_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly (called from quick_amount handlers).
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
await message.answer(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
# Validate amount
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.RIOPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}',
).format(min_amount=min_amount // 100),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}',
).format(max_amount=max_amount // 100),
parse_mode='HTML',
)
return
await state.clear()
await _create_riopay_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
)
@error_handler
async def start_riopay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Start RioPay top-up process - ask for amount.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method='riopay')
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.RIOPAY_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_riopay_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'RIOPAY_ENTER_AMOUNT',
'💳 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\n'
'Максимум: {max_amount}',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
@error_handler
async def process_riopay_custom_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process custom amount input for RioPay payment.
"""
data = await state.get_data()
if data.get('payment_method') != 'riopay':
return
texts = get_texts(db_user.language)
try:
amount_text = message.text.replace(',', '.').replace(' ', '').strip()
amount_rubles = float(amount_text)
amount_kopeks = round(amount_rubles * 100)
except (ValueError, TypeError):
await message.answer(
texts.t(
'PAYMENT_INVALID_AMOUNT',
'Введите корректную сумму числом.',
),
parse_mode='HTML',
)
return
await process_riopay_payment_amount(
message=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
state=state,
)
@error_handler
async def process_riopay_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process quick amount selection for RioPay payment.
Called when user clicks a predefined amount button.
"""
texts = get_texts(db_user.language)
if not settings.is_riopay_enabled():
await callback.answer(
texts.t('RIOPAY_NOT_AVAILABLE', 'RioPay временно недоступен'),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|riopay|{amount_kopeks}
try:
parts = callback.data.split('|')
if len(parts) >= 3:
amount_kopeks = int(parts[2])
else:
await callback.answer('Invalid callback data', show_alert=True)
return
except (ValueError, IndexError):
await callback.answer('Invalid amount', show_alert=True)
return
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
# Validate amount
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.RIOPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await callback.answer(
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
show_alert=True,
)
return
if amount_kopeks > max_amount:
await callback.answer(
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
show_alert=True,
)
return
await callback.answer()
await state.clear()
await _create_riopay_payment_and_respond(
message_or_callback=callback.message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
)
+50 -18
View File
@@ -1,3 +1,4 @@
import hashlib
import json
from pathlib import Path
@@ -37,10 +38,14 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
texts = get_texts(db_user.language)
if not db_user.referral_code:
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
return
summary = await get_user_referral_summary(db, db_user.id)
bot_username = (await callback.bot.get_me()).username
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
referral_text = (
texts.t('REFERRAL_PROGRAM_TITLE', '👥 <b>Реферальная программа</b>')
@@ -78,24 +83,40 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
).format(amount=texts.format_price(summary['month_earned_kopeks']))
+ '\n\n'
+ texts.t('REFERRAL_REWARDS_HEADER', '🎁 <b>Как работают награды:</b>')
+ '\n'
+ texts.t(
)
if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
referral_text += '\n' + texts.t(
'REFERRAL_REWARD_NEW_USER',
'• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>',
).format(
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS),
)
+ '\n'
+ texts.t(
if settings.REFERRAL_INVITER_BONUS_KOPEKS > 0:
referral_text += '\n' + texts.t(
'REFERRAL_REWARD_INVITER',
'• Вы получаете при первом пополнении реферала: <b>{bonus}</b>',
).format(bonus=texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS))
+ '\n'
+ texts.t(
if settings.REFERRAL_MAX_COMMISSION_PAYMENTS > 0:
commission_line = texts.t(
'REFERRAL_REWARD_COMMISSION_LIMITED',
'• Комиссия с первых {max_payments} пополнений реферала: <b>{percent}%</b>',
).format(
percent=get_effective_referral_commission_percent(db_user),
max_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
)
else:
commission_line = texts.t(
'REFERRAL_REWARD_COMMISSION',
'• Комиссия с каждого пополнения реферала: <b>{percent}%</b>',
).format(percent=get_effective_referral_commission_percent(db_user))
referral_text += (
'\n'
+ commission_line
+ '\n\n'
+ texts.t('REFERRAL_LINK_TITLE', '🔗 <b>Ваша реферальная ссылка:</b>')
+ f'\n<code>{referral_link}</code>\n\n'
@@ -213,17 +234,22 @@ async def show_referral_qr(
callback: types.CallbackQuery,
db_user: User,
):
await callback.answer()
texts = get_texts(db_user.language)
if not db_user.referral_code:
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
return
await callback.answer()
bot_username = (await callback.bot.get_me()).username
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
qr_dir = Path('data') / 'referral_qr'
qr_dir.mkdir(parents=True, exist_ok=True)
file_path = qr_dir / f'{db_user.id}.png'
link_hash = hashlib.md5(referral_link.encode()).hexdigest()[:8]
file_path = qr_dir / f'{db_user.id}_{link_hash}.png'
if not file_path.exists():
img = qrcode.make(referral_link)
img.save(file_path)
@@ -454,20 +480,26 @@ async def show_referral_analytics(callback: types.CallbackQuery, db_user: User,
async def create_invite_message(callback: types.CallbackQuery, db_user: User):
texts = get_texts(db_user.language)
bot_username = (await callback.bot.get_me()).username
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
if not db_user.referral_code:
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
return
invite_text = (
texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!')
+ '\n\n'
+ texts.t(
bot_username = (await callback.bot.get_me()).username
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
invite_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!')
if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
invite_text += '\n\n' + texts.t(
'REFERRAL_INVITE_BONUS',
'💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!',
).format(
minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS),
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
)
+ '\n\n'
invite_text += (
'\n\n'
+ texts.t('REFERRAL_INVITE_FEATURE_FAST', '🚀 Быстрое подключение')
+ '\n'
+ texts.t('REFERRAL_INVITE_FEATURE_SERVERS', '🌍 Серверы по всему миру')
+3 -2
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime
import structlog
@@ -169,7 +170,7 @@ def _split_into_pages(
pages.append((current_online, current_offline))
return pages if pages else [([], [])]
return pages or [([], [])]
def _format_server_lines(
@@ -189,7 +190,7 @@ def _format_server_lines(
else:
latency_text = texts.t('SERVER_STATUS_OFFLINE', 'нет ответа')
name = server.display_name or server.name
name = html.escape(server.display_name or server.name)
flag_prefix = f'{server.flag} ' if server.flag else ''
server_line = f'{flag_prefix}{name}{latency_text}'
lines.append(f'<blockquote>{server_line}</blockquote>')
+3 -1
View File
@@ -649,6 +649,7 @@ async def handle_simple_subscription_pay_with_balance(
subscription_params['period_days'],
False, # was_trial_conversion
amount_kopeks=price_kopeks,
purchase_type='renewal' if existing_subscription else 'first_purchase',
)
except Exception as e:
logger.error('Ошибка отправки уведомления админам о покупке', error=e)
@@ -970,7 +971,7 @@ async def handle_simple_subscription_payment_method(
from aiogram.types import BufferedInputFile
# Используем qr_confirmation_data если доступно, иначе confirmation_url
qr_data = qr_confirmation_data if qr_confirmation_data else confirmation_url
qr_data = qr_confirmation_data or confirmation_url
# Создаем QR-код из полученных данных
qr = qrcode.QRCode(version=1, box_size=10, border=5)
@@ -2368,6 +2369,7 @@ async def confirm_simple_subscription_purchase(
subscription_params['period_days'],
False, # was_trial_conversion
amount_kopeks=price_kopeks,
purchase_type='renewal' if existing_subscription else 'first_purchase',
)
except Exception as e:
logger.error('Ошибка отправки уведомления админам о покупке', error=e)
+97 -1
View File
@@ -285,6 +285,90 @@ async def _handle_trial_payment(
return False
_PURCHASE_TOKEN_RE = __import__('re').compile(r'^[A-Za-z0-9_\-]{10,100}$')
async def _handle_guest_purchase_payment(
message: types.Message,
db: AsyncSession,
user,
stars_amount: int,
payload: str,
telegram_payment_charge_id: str,
):
"""Обработка Stars платежа для гостевой покупки (подарочная подписка из кабинета)."""
from app.database.crud.landing import get_purchase_by_token
from app.services.payment.common import try_fulfill_guest_purchase
try:
purchase_token = payload[len('guest_purchase_') :]
if not purchase_token or not _PURCHASE_TOKEN_RE.match(purchase_token):
logger.error('Invalid purchase_token format in guest_purchase payload', payload=payload)
await message.answer('❌ Ошибка: неверный формат платежа.')
return
# Verify Stars amount matches expected price (±5% tolerance for conversion rounding)
existing = await get_purchase_by_token(db, purchase_token)
if existing and existing.amount_kopeks:
expected_stars = max(1, settings.rubles_to_stars(existing.amount_kopeks / 100))
tolerance = max(1, round(expected_stars * 0.05))
if abs(stars_amount - expected_stars) > tolerance:
logger.error(
'Stars amount mismatch for guest purchase',
paid_stars=stars_amount,
expected_stars=expected_stars,
purchase_token_prefix=purchase_token[:5],
)
await message.answer('❌ Сумма оплаты не совпадает с ожидаемой.')
return
# Calculate kopeks from stars
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
# Build metadata matching what other providers use
metadata = {
'purpose': 'guest_purchase',
'purchase_token': purchase_token,
}
result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=amount_kopeks,
provider_payment_id=telegram_payment_charge_id,
provider_name='telegram_stars',
skip_amount_check=True,
)
if result is True:
await message.answer(
'🎁 <b>Подарочная подписка успешно оплачена!</b>\n\n'
f'⭐ Потрачено: {stars_amount} Stars\n\n'
'Подарок будет доставлен получателю.',
parse_mode='HTML',
)
logger.info(
'✅ Guest purchase fulfilled via Stars',
user_id=user.id,
stars_amount=stars_amount,
purchase_token_prefix=purchase_token[:5],
)
elif result is False:
await message.answer(
'❌ Произошла ошибка при обработке подарочной подписки. Обратитесь в поддержку.',
)
else:
logger.error('try_fulfill_guest_purchase returned None for Stars gift', payload=payload)
await message.answer('❌ Ошибка обработки платежа. Обратитесь в поддержку.')
except Exception as e:
logger.error('Error handling guest purchase Stars payment', error=e, exc_info=True)
await message.answer(
'❌ Произошла ошибка при обработке подарочной подписки. Обратитесь в поддержку.',
)
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
texts = get_texts(DEFAULT_LANGUAGE)
@@ -296,7 +380,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
invoice_payload=query.invoice_payload,
)
allowed_prefixes = ('balance_', 'admin_stars_test_', 'simple_sub_', 'wheel_spin_', 'trial_')
allowed_prefixes = ('balance_', 'admin_stars_test_', 'simple_sub_', 'wheel_spin_', 'trial_', 'guest_purchase_')
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
logger.warning('Невалидный payload', invoice_payload=query.invoice_payload)
@@ -402,6 +486,18 @@ async def handle_successful_payment(message: types.Message, db: AsyncSession, st
)
return
# Обработка оплаты гостевой покупки (подарочная подписка из кабинета)
if payment.invoice_payload and payment.invoice_payload.startswith('guest_purchase_'):
await _handle_guest_purchase_payment(
message=message,
db=db,
user=user,
stars_amount=payment.total_amount,
payload=payment.invoice_payload,
telegram_payment_charge_id=payment.telegram_payment_charge_id,
)
return
payment_service = PaymentService(message.bot)
state_data = await state.get_data()
+106 -11
View File
@@ -1,7 +1,10 @@
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
import structlog
from aiogram import Bot, Dispatcher, F, types
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
@@ -21,7 +24,7 @@ from app.database.crud.user import (
get_user_by_telegram_id,
)
from app.database.crud.user_message import get_random_active_message
from app.database.models import PinnedMessage, SubscriptionStatus, UserStatus
from app.database.models import GuestPurchase, GuestPurchaseStatus, PinnedMessage, SubscriptionStatus, UserStatus
from app.keyboards.inline import (
get_back_keyboard,
get_language_selection_keyboard,
@@ -60,6 +63,73 @@ from app.utils.user_utils import generate_unique_referral_code
logger = structlog.get_logger(__name__)
async def _activate_pending_gift_after_registration(
db: AsyncSession,
state: FSMContext,
user: 'User',
answer_func: Callable[..., Any],
) -> None:
"""Extract pending_gift_token from FSM state and activate it for the newly registered user.
Must be called BEFORE state.clear() to preserve the token.
"""
gift_token: str | None = None
try:
fresh_state = await state.get_data()
gift_token = fresh_state.get('pending_gift_token')
if not gift_token:
return
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.services.guest_purchase_service import activate_purchase as svc_activate
# Support both full token and prefix-based lookup (Telegram truncates long start params)
if len(gift_token) >= 64:
token_filter = GuestPurchase.token == gift_token
else:
token_filter = GuestPurchase.token.startswith(gift_token)
gift_result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff))
.where(token_filter, GuestPurchase.is_gift.is_(True))
.with_for_update()
)
gift_purchase = gift_result.scalars().first()
if (
gift_purchase
and gift_purchase.is_gift
and gift_purchase.status
in (
GuestPurchaseStatus.PENDING_ACTIVATION.value,
GuestPurchaseStatus.PAID.value,
)
and (gift_purchase.user_id is None or gift_purchase.user_id == user.id)
and gift_purchase.buyer_user_id != user.id # prevent self-activation
):
if gift_purchase.user_id is None:
gift_purchase.user_id = user.id
# Transition PAID → PENDING_ACTIVATION so activate_purchase() accepts it
if gift_purchase.status == GuestPurchaseStatus.PAID.value:
gift_purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
await db.flush()
await svc_activate(db, gift_purchase.token, skip_notification=True)
tariff_name = gift_purchase.tariff.name if gift_purchase.tariff else ''
await answer_func(
f'🎁 <b>Подарок активирован!</b>\n'
f'{tariff_name}{gift_purchase.period_days} дн.\n\n'
f'Ваша подписка обновлена.',
parse_mode=ParseMode.HTML,
)
except Exception:
logger.exception(
'Failed to auto-activate gift after registration',
token_prefix=(gift_token or '')[:5],
)
async def _claim_phantom_user(
db: AsyncSession,
phantom: 'User',
@@ -446,6 +516,20 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
if state_needs_update:
await state.set_data(data)
# Handle gift code deep links: /start GIFT_{token}
if start_parameter and start_parameter.startswith('GIFT_'):
gift_token = start_parameter[5:] # Strip "GIFT_" prefix
if len(gift_token) >= 8:
logger.info(
'Gift code deep link detected',
token_prefix=gift_token[:5],
telegram_id=message.from_user.id,
)
# For new users, gift is auto-activated via
# _activate_pending_gift_after_registration() before state.clear().
await state.update_data(pending_gift_token=gift_token)
start_parameter = None # Don't treat as campaign or referral
if start_parameter:
campaign = await get_campaign_by_start_parameter(
db,
@@ -481,7 +565,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
if referral_code:
await state.update_data(referral_code=referral_code)
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
user = db_user or await get_user_by_telegram_id(db, message.from_user.id)
if campaign and not campaign_notification_sent:
try:
@@ -553,6 +637,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
except Exception as e:
logger.error('Ошибка отправки уведомления о рекламной кампании', error=e)
# Auto-activate pending gift if deep link contained GIFT_
if user:
await _activate_pending_gift_after_registration(db, state, user, message.answer)
await state.update_data(pending_gift_token=None)
# Refresh user to pick up newly created subscription
await db.refresh(user, attribute_names=['subscription'])
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
pinned_message = await get_active_pinned_message(db)
@@ -1364,6 +1455,9 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
telegram_id=user.telegram_id,
)
# Auto-activate pending gift for newly registered user (before state.clear() wipes the token)
await _activate_pending_gift_after_registration(db, state, user, callback.message.answer)
await state.clear()
if campaign_message:
@@ -1682,6 +1776,9 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
'🗑️ COMPLETE: Redis payload удален после успешной регистрации пользователя', telegram_id=user.telegram_id
)
# Auto-activate pending gift for newly registered user (before state.clear() wipes the token)
await _activate_pending_gift_after_registration(db, state, user, message.answer)
await state.clear()
if campaign_message:
@@ -2021,14 +2118,6 @@ async def required_sub_channel_check(
# Очищаем Redis после успешной проверки подписки
await delete_pending_payload_from_redis(query.from_user.id)
# Всегда обновляем referral_code если есть новый payload
# (исправление бага с устаревшими данными в state)
campaign = await get_campaign_by_start_parameter(
db,
pending_start_payload,
only_active=True,
)
# Обрабатываем payload только если ещё не обработан
# (проверяем по наличию referral_code или campaign_id в state)
if not state_data.get('referral_code') and not state_data.get('campaign_id'):
@@ -2040,7 +2129,13 @@ async def required_sub_channel_check(
if campaign:
state_data['campaign_id'] = campaign.id
logger.info('📣 CHANNEL CHECK: Кампания восстановлена из payload', campaign_id=campaign.id)
if campaign.partner_user_id:
state_data['referrer_id'] = campaign.partner_user_id
logger.info(
'📣 CHANNEL CHECK: Кампания восстановлена из payload',
campaign_id=campaign.id,
partner_user_id=campaign.partner_user_id,
)
else:
state_data['referral_code'] = pending_start_payload
logger.info(
+16 -13
View File
@@ -3,7 +3,7 @@ import base64
import html as html_mod
import re
import time
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
from urllib.parse import quote
@@ -15,7 +15,6 @@ from app.database.models import Subscription, User
from app.localization.texts import get_texts
from app.utils.pricing_utils import (
apply_percentage_discount,
get_remaining_months,
)
from app.utils.promo_offer import (
get_user_active_promo_discount_percent,
@@ -109,14 +108,15 @@ def _apply_promo_offer_discount(user: User | None, amount: int) -> dict[str, int
def _get_period_hint_from_subscription(subscription: Subscription | None) -> int | None:
if not subscription:
if not subscription or not subscription.end_date:
return None
months_remaining = get_remaining_months(subscription.end_date)
if months_remaining <= 0:
now = datetime.now(UTC)
days_remaining = (subscription.end_date - now).days
if days_remaining <= 0:
return None
return months_remaining * 30
return days_remaining
def _apply_discount_to_monthly_component(
@@ -518,12 +518,15 @@ def get_traffic_switch_keyboard(
if base_traffic_gb is None:
base_traffic_gb = current_traffic_gb
months_multiplier = 1
period_text = ''
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f' (за {months_multiplier} мес)'
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
price_multiplier = days_left / 30
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
else:
price_multiplier = 1
period_text = ''
packages = settings.get_traffic_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
@@ -546,7 +549,7 @@ def get_traffic_switch_keyboard(
)
price_diff_per_month = discounted_price_per_month - discounted_current_per_month
total_price_diff = price_diff_per_month * months_multiplier
total_price_diff = int(price_diff_per_month * price_multiplier)
# Сравниваем с базовым трафиком (без докупленного)
if gb == base_traffic_gb:
@@ -558,7 +561,7 @@ def get_traffic_switch_keyboard(
action_text = ''
price_text = f' (+{total_price_diff // 100}{period_text})'
if discount_percent > 0:
discount_total = (price_per_month - current_price_per_month) * months_multiplier - total_price_diff
discount_total = int((price_per_month - current_price_per_month) * price_multiplier) - total_price_diff
if discount_total > 0:
price_text += f' (скидка {discount_percent}%: -{discount_total // 100}₽)'
elif total_price_diff < 0:
+27 -22
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime
from aiogram import types
@@ -25,7 +26,6 @@ from app.states import SubscriptionStates
from app.utils.pricing_utils import (
apply_percentage_discount,
calculate_prorated_price,
get_remaining_months,
)
from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger
@@ -67,7 +67,7 @@ async def handle_add_countries(callback: types.CallbackQuery, db_user: User, db:
current_countries_names = []
for country in countries:
if country['uuid'] in current_countries:
current_countries_names.append(country['name'])
current_countries_names.append(html.escape(country['name']))
current_list = (
'\n'.join(f'{name}' for name in current_countries_names)
@@ -253,9 +253,10 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
logger.info('🔧 Добавлено: Удалено', added=added, removed=removed)
months_to_pay = get_remaining_months(subscription.end_date)
now = datetime.now(UTC)
days_to_pay = max(1, (subscription.end_date - now).days)
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
period_hint_days = days_to_pay if days_to_pay > 0 else None
servers_discount_percent = _get_addon_discount_percent_for_user(
db_user,
'servers',
@@ -290,24 +291,28 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
if country['uuid'] in removed:
removed_names.append(country['name'])
total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date)
total_cost, charged_days = calculate_prorated_price(cost_per_month, subscription.end_date)
added_server_prices = [component['discounted_per_month'] * charged_months for component in added_server_components]
added_server_prices = [
int(component['discounted_per_month'] * charged_days / 30) for component in added_server_components
]
total_discount = sum(component['discount_per_month'] * charged_months for component in added_server_components)
total_discount = sum(
int(component['discount_per_month'] * charged_days / 30) for component in added_server_components
)
if added_names:
logger.info(
'Стоимость новых серверов: ₽/мес × мес = ₽ (скидка ₽)',
'Стоимость новых серверов: ₽/мес × дн./30 = ₽ (скидка ₽)',
cost_per_month=cost_per_month / 100,
charged_months=charged_months,
charged_days=charged_days,
total_cost=total_cost / 100,
total_discount=total_discount / 100,
)
if total_cost > 0 and db_user.balance_kopeks < total_cost:
missing_kopeks = total_cost - db_user.balance_kopeks
required_text = f'{texts.format_price(total_cost)} (за {charged_months} мес)'
required_text = f'{texts.format_price(total_cost)} (за {charged_days} дн.)'
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
(
@@ -349,7 +354,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
try:
if added and total_cost > 0:
success = await subtract_user_balance(
db, db_user, total_cost, f'Добавление стран: {", ".join(added_names)} на {charged_months} мес'
db, db_user, total_cost, f'Добавление стран: {", ".join(added_names)} за {charged_days} дн.'
)
if not success:
await callback.answer(
@@ -363,7 +368,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=total_cost,
description=f'Добавление стран к подписке: {", ".join(added_names)} на {charged_months} мес',
description=f'Добавление стран к подписке: {", ".join(added_names)} за {charged_days} дн.',
)
if added:
@@ -377,8 +382,8 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
await add_user_to_servers(db, added_server_ids)
logger.info(
'📊 Добавлены серверы с ценами за мес',
charged_months=charged_months,
'📊 Добавлены серверы с ценами за дн.',
charged_days=charged_days,
value=list(zip(added_server_ids, added_server_prices, strict=False)),
)
@@ -415,10 +420,10 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
if total_cost > 0:
success_text += '\n' + texts.t(
'COUNTRY_CHANGES_CHARGED',
'💰 Списано: {amount} (за {months} мес)',
'💰 Списано: {amount} (за {days} дн.)',
).format(
amount=texts.format_price(total_cost),
months=charged_months,
days=charged_days,
)
if total_discount > 0:
success_text += texts.t(
@@ -655,8 +660,8 @@ def _build_countries_selection_text(countries: list[dict], base_text: str) -> st
continue
desc = country.get('description', '').strip()
if desc:
name = country.get('name', '')
descriptions.append(f'<b>{name}</b>\n{desc}')
name = html.escape(country.get('name', ''))
descriptions.append(f'<b>{name}</b>\n{html.escape(desc)}')
if not descriptions:
return base_text
@@ -830,16 +835,16 @@ async def confirm_add_countries_to_subscription(
discounted_per_month = server_price
discount_per_month = 0
charged_price, charged_months = calculate_prorated_price(
charged_price, charged_days = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
)
total_price += charged_price
total_discount_value += discount_per_month * charged_months
new_countries_names.append(country['name'])
total_discount_value += int(discount_per_month * charged_days / 30)
new_countries_names.append(html.escape(country['name']))
if country['uuid'] in removed_countries:
removed_countries_names.append(country['name'])
removed_countries_names.append(html.escape(country['name']))
if new_countries and db_user.balance_kopeks < total_price:
missing_kopeks = total_price - db_user.balance_kopeks
+18 -27
View File
@@ -27,7 +27,6 @@ from app.services.user_cart_service import user_cart_service
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
apply_percentage_discount,
get_remaining_months,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
@@ -83,7 +82,7 @@ async def get_servers_display_names(squad_uuids: list[str]) -> str:
for uuid in squad_uuids:
server = await get_server_squad_by_uuid(db, uuid)
if server:
server_names.append(server.display_name)
server_names.append(html_mod.escape(server.display_name))
logger.debug('Найден сервер в БД', uuid=uuid, display_name=server.display_name)
else:
logger.warning('Сервер с UUID не найден в БД', uuid=uuid)
@@ -93,7 +92,7 @@ async def get_servers_display_names(squad_uuids: list[str]) -> str:
for uuid in squad_uuids:
for country in countries:
if country['uuid'] == uuid:
server_names.append(country['name'])
server_names.append(html_mod.escape(country['name']))
logger.debug('Найден сервер в кэше', uuid=uuid, country=country['name'])
break
@@ -278,11 +277,7 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
# Используем max_device_limit из тарифа если есть, иначе глобальную настройку
tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None
effective_max = (
tariff_max_devices
if tariff_max_devices
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
)
effective_max = tariff_max_devices or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
if effective_max and new_devices_count > effective_max:
await callback.answer(
texts.t(
@@ -551,13 +546,13 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
)
return
charged_months = get_remaining_months(subscription.end_date)
charged_days = max(1, (subscription.end_date - datetime.now(UTC)).days)
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price,
description=f'Изменение устройств с {current_devices} до {new_devices_count} на {charged_months} мес',
description=f'Изменение устройств с {current_devices} до {new_devices_count} за {charged_days} дн.',
)
# Re-lock subscription after subtract_user_balance committed (released all locks)
@@ -572,11 +567,7 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
# Re-validate: prevent double-charge and max-limit violation
if new_devices_count > current_devices:
tariff_max_recheck = getattr(tariff, 'max_device_limit', None) if tariff else None
max_devices = (
tariff_max_recheck
if tariff_max_recheck
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
)
max_devices = tariff_max_recheck or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
if max_devices and new_devices_count > max_devices:
if price > 0:
user_refund = await db.execute(
@@ -615,7 +606,7 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
await db.commit()
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
from app.database.crud.subscription import reactivate_subscription
await reactivate_subscription(db, subscription)
@@ -623,6 +614,10 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if db_user.remnawave_uuid and subscription.status == 'active':
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
# При уменьшении лимита - удалить лишние устройства (последние подключённые)
devices_reset_count = 0
if new_devices_count < current_devices and db_user.remnawave_uuid:
@@ -1140,11 +1135,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
# Используем max_device_limit из тарифа если есть, иначе глобальную настройку
tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None
effective_max = (
tariff_max_devices
if tariff_max_devices
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
)
effective_max = tariff_max_devices or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
if effective_max and new_total_devices > effective_max:
await callback.answer(
texts.t(
@@ -1279,11 +1270,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
actual_current = subscription.device_limit or 1
actual_new = actual_current + devices_count
tariff_max_recheck = getattr(tariff, 'max_device_limit', None) if tariff else None
max_devices = (
tariff_max_recheck
if tariff_max_recheck
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
)
max_devices = tariff_max_recheck or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
if max_devices and actual_new > max_devices:
# Concurrent purchase exceeded limit — refund
user_refund = await db.execute(
@@ -1302,7 +1289,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
subscription.updated_at = datetime.now(UTC)
await db.commit()
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
from app.database.crud.subscription import reactivate_subscription
await reactivate_subscription(db, subscription)
@@ -1310,6 +1297,10 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if db_user.remnawave_uuid and subscription.status == 'active':
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
await create_transaction(
db=db,
user_id=db_user.id,
+8 -1
View File
@@ -27,6 +27,7 @@ async def send_purchase_notification(
transaction_id: int,
period_days: int,
was_trial_conversion: bool = False,
purchase_type: str | None = None,
):
try:
from app.database.crud.transaction import get_transaction_by_id
@@ -35,7 +36,13 @@ async def send_purchase_notification(
if transaction:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_purchase_notification(
db, db_user, subscription, transaction, period_days, was_trial_conversion
db,
db_user,
subscription,
transaction,
period_days,
was_trial_conversion,
purchase_type=purchase_type,
)
except Exception as e:
logger.error('Ошибка отправки уведомления о покупке', error=e)
+2 -1
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime
from typing import Any
@@ -74,7 +75,7 @@ async def _prepare_subscription_summary(
if country['uuid'] in selected_country_ids:
server_price_per_month = country['price_kopeks']
countries_price_per_month += server_price_per_month
selected_countries_names.append(country['name'])
selected_countries_names.append(html.escape(country['name']))
server_monthly_prices.append(server_price_per_month)
servers_discount_percent = db_user.get_promo_discount(
+11 -4
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -313,7 +314,7 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
devices_used_str = str(devices_used)
servers_names = await get_servers_display_names(subscription.connected_squads)
servers_display = servers_names if servers_names else texts.t('SUBSCRIPTION_NO_SERVERS', 'Нет серверов')
servers_display = servers_names or texts.t('SUBSCRIPTION_NO_SERVERS', 'Нет серверов')
# Получаем информацию о тарифе для режима тарифов
tariff_info_block = ''
@@ -623,7 +624,7 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
tariff_squads = await get_server_squads_by_uuids(db, trial_tariff.allowed_squads)
if tariff_squads:
if len(tariff_squads) == 1:
trial_server_name = tariff_squads[0].display_name
trial_server_name = html.escape(tariff_squads[0].display_name)
else:
trial_server_name = texts.t(
'TRIAL_SERVER_RANDOM_POOL',
@@ -633,7 +634,7 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
if trial_squads:
if len(trial_squads) == 1:
trial_server_name = trial_squads[0].display_name
trial_server_name = html.escape(trial_squads[0].display_name)
else:
trial_server_name = texts.t(
'TRIAL_SERVER_RANDOM_POOL',
@@ -2821,7 +2822,13 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_purchase_notification(
db, db_user, subscription, transaction, period_days, was_trial_conversion
db,
db_user,
subscription,
transaction,
period_days,
was_trial_conversion,
purchase_type='renewal' if existing_subscription else 'first_purchase',
)
except Exception as e:
logger.error('Ошибка отправки уведомления о покупке', error=e)
@@ -909,6 +909,7 @@ async def handle_custom_confirm(
custom_days,
was_trial_conversion=False,
amount_kopeks=total_price,
purchase_type='renewal' if existing_subscription else 'first_purchase',
)
except Exception as e:
logger.error('Ошибка отправки уведомления админу', error=e)
@@ -1221,6 +1222,7 @@ async def confirm_tariff_purchase(
period,
was_trial_conversion=False,
amount_kopeks=final_price,
purchase_type='renewal' if existing_subscription else 'first_purchase',
)
except Exception as e:
logger.error('Ошибка отправки уведомления админу', error=e)
@@ -1404,6 +1406,7 @@ async def confirm_daily_tariff_purchase(
1, # 1 день
was_trial_conversion=False,
amount_kopeks=daily_price,
purchase_type='renewal' if existing_subscription else 'first_purchase',
)
except Exception as e:
logger.error('Ошибка отправки уведомления админу', error=e)
@@ -1765,6 +1768,7 @@ async def confirm_tariff_extend(
period,
was_trial_conversion=False,
amount_kopeks=final_price,
purchase_type='renewal',
)
except Exception as e:
logger.error('Ошибка отправки уведомления админу', error=e)
+23 -14
View File
@@ -26,7 +26,6 @@ from app.states import SubscriptionStates
from app.utils.pricing_utils import (
apply_percentage_discount,
calculate_prorated_price,
get_remaining_months,
)
from .common import (
@@ -482,7 +481,7 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
discounted_per_month = discount_result['discounted']
discount_per_month = discount_result['discount']
charged_months = 1
charged_days = 30
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
@@ -492,14 +491,14 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
if is_tariff_mode:
price = discounted_per_month
elif subscription:
price, charged_months = calculate_prorated_price(
price, charged_days = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
)
else:
price = discounted_per_month
total_discount_value = discount_per_month * charged_months
total_discount_value = int(discount_per_month * charged_days / 30)
if db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
@@ -578,12 +577,16 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
# add_subscription_traffic уже создаёт TrafficPurchase и обновляет все необходимые поля
await add_subscription_traffic(db, subscription, traffic_gb)
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
await reactivate_subscription(db, subscription)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if db_user.remnawave_uuid and subscription.status == 'active':
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
await create_transaction(
db=db,
user_id=db_user.id,
@@ -716,8 +719,9 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d
old_price_per_month = settings.get_traffic_price(base_traffic)
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
months_remaining = get_remaining_months(subscription.end_date)
period_hint_days = months_remaining * 30 if months_remaining > 0 else None
now = datetime.now(UTC)
days_remaining = max(1, (subscription.end_date - now).days)
period_hint_days = days_remaining if days_remaining > 0 else None
traffic_discount_percent = _get_addon_discount_percent_for_user(
db_user,
'traffic',
@@ -736,7 +740,8 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d
discount_savings_per_month = (new_price_per_month - old_price_per_month) - price_difference_per_month
if price_difference_per_month > 0:
total_price_difference = price_difference_per_month * months_remaining
total_price_difference = int(price_difference_per_month * days_remaining / 30)
total_price_difference = max(100, total_price_difference)
if db_user.balance_kopeks < total_price_difference:
missing_kopeks = total_price_difference - db_user.balance_kopeks
@@ -750,7 +755,7 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d
'Выберите способ пополнения. Сумма подставится автоматически.'
),
).format(
required=f'{texts.format_price(total_price_difference)} (за {months_remaining} мес)',
required=f'{texts.format_price(total_price_difference)} (за {days_remaining} дн.)',
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
@@ -767,9 +772,9 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d
return
action_text = f'увеличить до {texts.format_traffic(new_traffic_gb)}'
cost_text = f'Доплата: {texts.format_price(total_price_difference)} (за {months_remaining} мес)'
cost_text = f'Доплата: {texts.format_price(total_price_difference)} (за {days_remaining} дн.)'
if discount_savings_per_month > 0:
total_discount_savings = discount_savings_per_month * months_remaining
total_discount_savings = int(discount_savings_per_month * days_remaining / 30)
cost_text += f' (скидка {traffic_discount_percent}%: -{texts.format_price(total_discount_savings)})'
else:
total_price_difference = 0
@@ -811,13 +816,13 @@ async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, d
await callback.answer('⚠️ Ошибка списания средств', show_alert=True)
return
months_remaining = get_remaining_months(subscription.end_date)
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price_difference,
description=f'Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB на {months_remaining} мес',
description=f'Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB за {days_remaining} дн.',
)
subscription.traffic_limit_gb = new_traffic_gb
@@ -833,12 +838,16 @@ async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, d
await db.commit()
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
await reactivate_subscription(db, subscription)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if db_user.remnawave_uuid and subscription.status == 'active':
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
await db.refresh(db_user)
await db.refresh(subscription)
+1 -1
View File
@@ -504,7 +504,7 @@ def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int
if current.strip():
pages.append(current)
return pages if pages else [header]
return pages or [header]
async def view_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
+46 -38
View File
@@ -1693,6 +1693,18 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_riopay_enabled():
riopay_name = settings.get_riopay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_RIOPAY', f'💳 Банковская карта ({riopay_name})'),
callback_data=_build_callback('riopay'),
)
]
)
has_direct_payment_methods = True
if settings.is_support_topup_enabled():
keyboard.append(
[
@@ -1957,18 +1969,20 @@ def get_add_traffic_keyboard(
discount_percent: int = 0,
) -> InlineKeyboardMarkup:
from app.config import settings
from app.utils.pricing_utils import get_remaining_months
texts = get_texts(language)
language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower()
use_russian_fallback = language_code in {'ru', 'fa'}
months_multiplier = 1
period_text = ''
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f' (за {months_multiplier} мес)'
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
price_multiplier = days_left / 30
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
else:
price_multiplier = 1
period_text = ''
packages = settings.get_traffic_topup_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled'] and pkg['price'] > 0]
@@ -1995,8 +2009,9 @@ def get_add_traffic_keyboard(
price_per_month,
discount_percent,
)
total_price = discounted_per_month * months_multiplier
total_discount = discount_per_month * months_multiplier
total_price = int(discounted_per_month * price_multiplier)
total_price = max(100, total_price) if total_price > 0 else 0
total_discount = int(discount_per_month * price_multiplier)
if gb == 0:
if use_russian_fallback:
@@ -2094,30 +2109,18 @@ def get_change_devices_keyboard(
tariff=None, # Тариф для цены за устройство
) -> InlineKeyboardMarkup:
from app.config import settings
from app.utils.pricing_utils import get_remaining_months
texts = get_texts(language)
# Проверяем является ли тариф суточным
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
# Для суточных тарифов считаем по дням, для обычных - по месяцам
if is_daily_tariff and subscription_end_date:
# Суточный тариф: цена за оставшиеся дни (обычно 1 день)
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
# Множитель = days_left / 30 (как в кабинете)
price_multiplier = days_left / 30
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
else:
# Обычный тариф: цена за оставшиеся месяцы
months_multiplier = 1
price_multiplier = 1
period_text = ''
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f' (за {months_multiplier} мес)'
price_multiplier = months_multiplier
# Используем цену из тарифа если есть, иначе глобальную настройку
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
@@ -2266,18 +2269,21 @@ def get_manage_countries_keyboard(
subscription_end_date: datetime = None,
discount_percent: int = 0,
) -> InlineKeyboardMarkup:
from app.utils.pricing_utils import get_remaining_months
texts = get_texts(language)
months_multiplier = 1
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
price_multiplier = days_left / 30
logger.info(
'🔍 Расчет для управления странами: осталось месяцев до',
months_multiplier=months_multiplier,
'🔍 Расчет для управления странами: осталось дней до',
days_left=days_left,
subscription_end_date=subscription_end_date,
)
else:
price_multiplier = 1
days_left = 30
buttons = []
total_cost = 0
@@ -2302,26 +2308,28 @@ def get_manage_countries_keyboard(
icon = ''
elif uuid in selected:
icon = ''
total_cost += discounted_per_month * months_multiplier
total_cost += int(discounted_per_month * price_multiplier)
else:
icon = ''
if uuid not in current_subscription_countries and uuid in selected:
total_price = discounted_per_month * months_multiplier
if months_multiplier > 1:
price_text = f' ({discounted_per_month // 100}₽/мес × {months_multiplier} = {total_price // 100}₽)'
total_price = int(discounted_per_month * price_multiplier)
total_price = max(100, total_price) if total_price > 0 else 0
if days_left > 30:
price_text = f' ({discounted_per_month // 100}₽/мес × {days_left} дн. = {total_price // 100}₽)'
logger.info(
'🔍 Сервер : ₽/мес × мес = ₽ (скидка ₽)',
'🔍 Сервер : ₽/мес × дн./30 = ₽ (скидка ₽)',
name=name,
discounted_per_month=discounted_per_month / 100,
months_multiplier=months_multiplier,
days_left=days_left,
total_price=total_price / 100,
discount_per_month=(discount_per_month * months_multiplier) / 100,
discount_per_month=int(discount_per_month * price_multiplier) / 100,
)
else:
price_text = f' ({total_price // 100}₽)'
if discount_percent > 0 and discount_per_month * months_multiplier > 0:
price_text += f' (скидка {discount_percent}%: -{(discount_per_month * months_multiplier) // 100}₽)'
total_discount_for_server = int(discount_per_month * price_multiplier)
if discount_percent > 0 and total_discount_for_server > 0:
price_text += f' (скидка {discount_percent}%: -{total_discount_for_server // 100}₽)'
display_name = f'{icon} {name}{price_text}'
else:
display_name = f'{icon} {name}'
+12
View File
@@ -889,8 +889,16 @@
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days",
"AUTOPAY_STATUS_DISABLED": "disabled",
"AUTOPAY_STATUS_ENABLED": "enabled",
"AUTOPAY_STATUS_CARD_ACTIVE": "✅ Enabled — automatic card charge scheduled",
"AUTOPAY_STATUS_NO_CARD": "✅ Enabled — subscription will renew automatically",
"AUTOPAY_STATUS_OFF": "❌ Disabled — don't forget to renew manually!",
"AUTOPAY_ACTION_CHECK_BALANCE": "💰 Make sure you have enough balance: {balance}",
"AUTOPAY_ACTION_ENABLE": "💡 Enable autopay or renew your subscription manually",
"AUTOPAY_ACTION_RENEW": "💡 Renew your subscription manually",
"AUTOPAY_SUCCESS": "\n✅ <b>Autopay completed</b>\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Auto-payment completed</b>\n\nBalance topped up by {amount} for subscription renewal.",
"RECURRENT_TOPUP_FAILED": "❌ <b>Auto-payment failed</b>\n\nCould not charge {amount} from any saved card for subscription renewal.\n\nPlease top up your balance manually to avoid service interruption.",
"BACK": "⬅️ Back",
"BACK_BUTTON": "◀️ Back",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu",
@@ -1191,6 +1199,9 @@
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>SBP (YooKassa)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Bank card (WATA)</b>",
"PAYMENT_METHOD_RIOPAY_DESCRIPTION": "via RioPay",
"PAYMENT_METHOD_RIOPAY_NAME": "💳 <b>Bank card (RioPay)</b>",
"PAYMENT_RIOPAY": "💳 Bank card (RioPay)",
"PAYMENT_HELEKET_MARKUP_LABEL": "Provider markup",
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Provider discount",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 Main menu",
@@ -1308,6 +1319,7 @@
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> from {referral_name}",
"REFERRAL_REWARDS_HEADER": "🎁 <b>How rewards work:</b>",
"REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: <b>{percent}%</b>",
"REFERRAL_REWARD_COMMISSION_LIMITED": "• Commission from the first {max_payments} referral top-ups: <b>{percent}%</b>",
"REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: <b>{bonus}</b>",
"REFERRAL_REWARD_NEW_USER": "• New user receives: <b>{bonus}</b> on the first top-up from <b>{minimum}</b>",
"REFERRAL_SHARE_BUTTON": "📤 Share",
+13 -1
View File
@@ -909,8 +909,16 @@
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ تنظیم روزها",
"AUTOPAY_STATUS_DISABLED": "غیرفعال",
"AUTOPAY_STATUS_ENABLED": "فعال",
"AUTOPAY_STATUS_CARD_ACTIVE": "✅ فعال — کارت به صورت خودکار شارژ می‌شود",
"AUTOPAY_STATUS_NO_CARD": "✅ فعال — اشتراک به صورت خودکار تمدید می‌شود",
"AUTOPAY_STATUS_OFF": "❌ غیرفعال — فراموش نکنید دستی تمدید کنید!",
"AUTOPAY_ACTION_CHECK_BALANCE": "💰 مطمئن شوید موجودی کافی دارید: {balance}",
"AUTOPAY_ACTION_ENABLE": "💡 پرداخت خودکار را فعال کنید یا اشتراک را دستی تمدید کنید",
"AUTOPAY_ACTION_RENEW": "💡 اشتراک را دستی تمدید کنید",
"AUTOPAY_SUCCESS": "\n✅ <b>پرداخت خودکار انجام شد</b>\n\nاشتراک {days} روز تمدید شد.\nکسر: {amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅ پرداخت خودکار {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>پرداخت خودکار انجام شد</b>\n\nموجودی به مبلغ {amount} برای تمدید اشتراک شارژ شد.",
"RECURRENT_TOPUP_FAILED": "❌ <b>پرداخت خودکار ناموفق بود</b>\n\nامکان کسر {amount} از هیچ کارت ذخیره شده‌ای برای تمدید اشتراک وجود نداشت.\n\nلطفاً موجودی را به صورت دستی شارژ کنید.",
"BACK": "⬅️ قبلی",
"BACK_BUTTON": "◀️ بازگشت",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ منوی اصلی",
@@ -1212,6 +1220,9 @@
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>پرداخت سریع (YooKassa)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "از طریق WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>کارت بانکی (WATA)</b>",
"PAYMENT_METHOD_RIOPAY_DESCRIPTION": "از طریق RioPay",
"PAYMENT_METHOD_RIOPAY_NAME": "💳 <b>کارت بانکی (RioPay)</b>",
"PAYMENT_RIOPAY": "💳 کارت بانکی (RioPay)",
"PAYMENT_HELEKET_MARKUP_LABEL": "هزینه اضافی ارائه‌دهنده",
"PAYMENT_HELEKET_DISCOUNT_LABEL": "تخفیف ارائه‌دهنده",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 صفحه اصلی",
@@ -1329,6 +1340,7 @@
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> از {referral_name}",
"REFERRAL_REWARDS_HEADER": "🎁 <b>نحوه عملکرد پاداش‌ها:</b>",
"REFERRAL_REWARD_COMMISSION": "• کمیسیون از هر شارژ دعوت‌شده: <b>{percent}%</b>",
"REFERRAL_REWARD_COMMISSION_LIMITED": "• کمیسیون از {max_payments} شارژ اول دعوت‌شده: <b>{percent}%</b>",
"REFERRAL_REWARD_INVITER": "• پاداش اولین شارژ دعوت‌شده: <b>{bonus}</b>",
"REFERRAL_REWARD_NEW_USER": "• کاربر جدید دریافت می‌کند: <b>{bonus}</b> با اولین شارژ از <b>{minimum}</b>",
"REFERRAL_SHARE_BUTTON": "📤 اشتراک‌گذاری",
@@ -1429,7 +1441,7 @@
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>تخفیف {percent}% برای تمدید</b>\n\nپیشنهاد را فعال کنید تا تخفیف اضافی بگیرید. با گروه تخفیف جمع شده و تا {expires_at} اعتبار دارد.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>تخفیف اختصاصی {percent}%</b>\n\n{trigger_days} روز بدون اشتراک گذشته. برگردید و تخفیف اضافی فعال کنید — با گروه تخفیف جمع شده و تا {expires_at} اعتبار دارد.",
"SUBSCRIPTION_EXPIRING": "⏰ اشتراک به‌زودی منقضی می‌شود!",
"SUBSCRIPTION_EXPIRING_PAID": "اشتراک {days} روز دیگر منقضی می‌شود.",
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>اشتراک تا {days_text} دیگر منقضی می‌شود!</b>\n\nاشتراک پولی شما در تاریخ {end_date} منقضی می‌شود.\n\n💳 <b>پرداخت خودکار:</b> {autopay_status}\n\n{action_text}\n",
"SUBSCRIPTION_EXTEND": "🔄 تمدید اشتراک",
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 لینک اشتراک ایجاد شد. دکمه «اتصال» بزنید تا در Happ باز شود.",
+12
View File
@@ -909,8 +909,16 @@
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни",
"AUTOPAY_STATUS_DISABLED": "выключен",
"AUTOPAY_STATUS_ENABLED": "включен",
"AUTOPAY_STATUS_CARD_ACTIVE": "✅ Включен — будет автоматическое списание с карты",
"AUTOPAY_STATUS_NO_CARD": "✅ Включен — подписка продлится автоматически",
"AUTOPAY_STATUS_OFF": "❌ Отключен — не забудьте продлить вручную!",
"AUTOPAY_ACTION_CHECK_BALANCE": "💰 Убедитесь, что на балансе достаточно средств: {balance}",
"AUTOPAY_ACTION_ENABLE": "💡 Включите автоплатеж или продлите подписку вручную",
"AUTOPAY_ACTION_RENEW": "💡 Продлите подписку вручную",
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Автоплатёж выполнен</b>\n\nБаланс пополнен на {amount} для продления подписки.",
"RECURRENT_TOPUP_FAILED": "❌ <b>Автоплатёж не удался</b>\n\nНе удалось списать {amount} ни с одной сохранённой карты для продления подписки.\n\nПополните баланс вручную, чтобы подписка не прервалась.",
"BACK": "⬅️ Назад",
"BACK_BUTTON": "◀️ Назад",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню",
@@ -1212,6 +1220,9 @@
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>СБП (YooKassa)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банковская карта (WATA)</b>",
"PAYMENT_METHOD_RIOPAY_DESCRIPTION": "через RioPay",
"PAYMENT_METHOD_RIOPAY_NAME": "💳 <b>Банковская карта (RioPay)</b>",
"PAYMENT_RIOPAY": "💳 Банковская карта (RioPay)",
"PAYMENT_HELEKET_MARKUP_LABEL": "Наценка провайдера",
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Скидка провайдера",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную",
@@ -1329,6 +1340,7 @@
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> от {referral_name}",
"REFERRAL_REWARDS_HEADER": "🎁 <b>Как работают награды:</b>",
"REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
"REFERRAL_REWARD_COMMISSION_LIMITED": "• Комиссия с первых {max_payments} пополнений реферала: <b>{percent}%</b>",
"REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: <b>{bonus}</b>",
"REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>",
"REFERRAL_SHARE_BUTTON": "📤 Поделиться",
+12
View File
@@ -831,8 +831,16 @@
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Налаштувати дні",
"AUTOPAY_STATUS_DISABLED": "вимкнено",
"AUTOPAY_STATUS_ENABLED": "увімкнено",
"AUTOPAY_STATUS_CARD_ACTIVE": "✅ Увімкнено — буде автоматичне списання з картки",
"AUTOPAY_STATUS_NO_CARD": "✅ Увімкнено — підписка продовжиться автоматично",
"AUTOPAY_STATUS_OFF": "❌ Вимкнено — не забудьте продовжити вручну!",
"AUTOPAY_ACTION_CHECK_BALANCE": "💰 Переконайтеся, що на балансі достатньо коштів: {balance}",
"AUTOPAY_ACTION_ENABLE": "💡 Увімкніть автоплатіж або продовжіть підписку вручну",
"AUTOPAY_ACTION_RENEW": "💡 Продовжіть підписку вручну",
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатіж виконано</b>\n\nВашу підписку автоматично продовжено на {days} днів.\nСписано з балансу: {amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатіж {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Автоплатіж виконано</b>\n\nБаланс поповнено на {amount} для продовження підписки.",
"RECURRENT_TOPUP_FAILED": "❌ <b>Автоплатіж не вдався</b>\n\nНе вдалося списати {amount} з жодної збереженої картки для продовження підписки.\n\nПоповніть баланс вручну, щоб підписка не перервалася.",
"BACK": "⬅️ Назад",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В головне меню",
"BACK_TO_MENU": "🏠 В головне меню",
@@ -1129,6 +1137,9 @@
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>СБП (YooKassa)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банківська картка (WATA)</b>",
"PAYMENT_METHOD_RIOPAY_DESCRIPTION": "через RioPay",
"PAYMENT_METHOD_RIOPAY_NAME": "💳 <b>Банківська картка (RioPay)</b>",
"PAYMENT_RIOPAY": "💳 Банківська картка (RioPay)",
"PAYMENT_HELEKET_MARKUP_LABEL": "Націнка провайдера",
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Знижка провайдера",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На головну",
@@ -1245,6 +1256,7 @@
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> від {referral_name}",
"REFERRAL_REWARDS_HEADER": "🎁 <b>Як працюють нагороди:</b>",
"REFERRAL_REWARD_COMMISSION": "• Комісія з кожного поповнення реферала: <b>{percent}%</b>",
"REFERRAL_REWARD_COMMISSION_LIMITED": "• Комісія з перших {max_payments} поповнень реферала: <b>{percent}%</b>",
"REFERRAL_REWARD_INVITER": "• Ви отримуєте при першому поповненні реферала: <b>{bonus}</b>",
"REFERRAL_REWARD_NEW_USER": "• Новий користувач отримує: <b>{bonus}</b> при першому поповненні від <b>{minimum}</b>",
"REFERRAL_SHARE_BUTTON": "📤 Поділитися",
+12
View File
@@ -829,8 +829,16 @@
"AUTOPAY_SET_DAYS_BUTTON": "⚙️设置天数",
"AUTOPAY_STATUS_DISABLED": "已禁用",
"AUTOPAY_STATUS_ENABLED": "已启用",
"AUTOPAY_STATUS_CARD_ACTIVE": "✅ 已启用 — 将自动从银行卡扣款",
"AUTOPAY_STATUS_NO_CARD": "✅ 已启用 — 订阅将自动续订",
"AUTOPAY_STATUS_OFF": "❌ 已禁用 — 请别忘了手动续订!",
"AUTOPAY_ACTION_CHECK_BALANCE": "💰 请确保余额充足:{balance}",
"AUTOPAY_ACTION_ENABLE": "💡 请启用自动支付或手动续订",
"AUTOPAY_ACTION_RENEW": "💡 请手动续订",
"AUTOPAY_SUCCESS": "\n✅<b>自动支付成功</b>\n\n您的订阅已自动延长{days}天。\n已从余额扣除:{amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅自动支付{status}",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>自动扣款成功</b>\n\n余额已充值{amount},用于续订订阅。",
"RECURRENT_TOPUP_FAILED": "❌ <b>自动扣款失败</b>\n\n无法从任何已保存的银行卡中扣除{amount}以续订订阅。\n\n请手动充值余额以避免服务中断。",
"BACK": "⬅️返回",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️返回主菜单",
"BACK_TO_MENU": "🏠返回主菜单",
@@ -1127,6 +1135,9 @@
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦<b>SBP(YooKassa)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "通过WATA",
"PAYMENT_METHOD_WATA_NAME": "💳<b>银行卡(WATA)</b>",
"PAYMENT_METHOD_RIOPAY_DESCRIPTION": "通过RioPay",
"PAYMENT_METHOD_RIOPAY_NAME": "💳<b>银行卡(RioPay)</b>",
"PAYMENT_RIOPAY": "💳 银行卡(RioPay)",
"PAYMENT_HELEKET_MARKUP_LABEL": "服务商加价",
"PAYMENT_HELEKET_DISCOUNT_LABEL": "服务商折扣",
"PAYMENT_RETURN_HOME_BUTTON": "🏠返回首页",
@@ -1243,6 +1254,7 @@
"REFERRAL_RECENT_EARNINGS_ITEM": "•{reason}:<b>{amount}</b>(来自{referral_name})",
"REFERRAL_REWARDS_HEADER": "🎁<b>奖励如何运作:</b>",
"REFERRAL_REWARD_COMMISSION": "•每次推荐充值的佣金:<b>{percent}%</b>",
"REFERRAL_REWARD_COMMISSION_LIMITED": "•前{max_payments}次推荐充值的佣金:<b>{percent}%</b>",
"REFERRAL_REWARD_INVITER": "•推荐首次充值时您将获得:<b>{bonus}</b>",
"REFERRAL_REWARD_NEW_USER": "•新用户首次充值<b>{minimum}</b>起将获得:<b>{bonus}</b>",
"REFERRAL_SHARE_BUTTON": "📤分享",
+2 -2
View File
@@ -398,7 +398,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
# Notify user about deactivation
try:
normalized = _normalize_channels(channels)
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
texts = get_texts(user.language or DEFAULT_LANGUAGE)
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
'🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n'
@@ -468,7 +468,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
# Notify user about reactivation
try:
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
texts = get_texts(user.language or DEFAULT_LANGUAGE)
notification_text = texts.t(
'SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE',
'✅ Ваша подписка восстановлена!\n\nСпасибо, что подписались на канал. VPN снова работает.',
+5 -2
View File
@@ -401,7 +401,7 @@ class AdminNotificationService:
period_days: int,
was_trial_conversion: bool = False,
amount_kopeks: int | None = None,
purchase_type: str | None = None, # 'tariff_switch', 'renewal', None (auto)
purchase_type: str | None = None, # 'first_purchase', 'renewal', 'tariff_switch', None (auto-detect)
) -> bool:
try:
total_amount = (
@@ -436,7 +436,10 @@ class AdminNotificationService:
elif was_trial_conversion:
event_title = '🔄 КОНВЕРСИЯ ИЗ ТРИАЛА'
user_status = 'Конверсия'
elif purchase_type == 'renewal' or user.has_had_paid_subscription:
elif purchase_type == 'first_purchase':
event_title = '💎 ПОКУПКА ПОДПИСКИ'
user_status = 'Первая покупка'
elif purchase_type == 'renewal' or (purchase_type is None and user.has_had_paid_subscription):
event_title = '💎 ПРОДЛЕНИЕ ПОДПИСКИ'
user_status = 'Продление'
else:
+50 -42
View File
@@ -347,7 +347,7 @@ class BackupService:
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
staging_dir = temp_path / 'backup'
staging_dir.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(lambda: staging_dir.mkdir(parents=True, exist_ok=True))
database_info = await self._dump_database(staging_dir, include_logs=include_logs)
database_info.setdefault('tables_count', overview.get('tables_count', 0))
@@ -376,10 +376,10 @@ class BackupService:
mode = 'w:gz' if compress else 'w'
with tarfile.open(backup_path, mode) as tar:
for item in staging_dir.iterdir():
for item in await asyncio.to_thread(lambda: list(staging_dir.iterdir())):
tar.add(item, arcname=item.name)
file_size = backup_path.stat().st_size
file_size = (await asyncio.to_thread(backup_path.stat)).st_size
await self._cleanup_old_backups()
@@ -415,7 +415,7 @@ class BackupService:
logger.info('📄 Начинаем восстановление из', backup_file_path=backup_file_path)
backup_path = Path(backup_file_path)
if not backup_path.exists():
if not await asyncio.to_thread(backup_path.exists):
return False, f'❌ Файл бекапа не найден: {backup_file_path}'
if self._is_archive_backup(backup_path):
@@ -473,7 +473,11 @@ class BackupService:
if pg_dump_path:
dump_path = staging_dir / 'database.sql'
await self._dump_postgres(dump_path, pg_dump_path)
size = dump_path.stat().st_size if dump_path.exists() else 0
size = (
(await asyncio.to_thread(dump_path.stat)).st_size
if await asyncio.to_thread(dump_path.exists)
else 0
)
return {
'type': 'postgresql',
'path': dump_path.name,
@@ -488,7 +492,7 @@ class BackupService:
dump_path = staging_dir / 'database.sqlite'
await self._dump_sqlite(dump_path)
size = dump_path.stat().st_size if dump_path.exists() else 0
size = (await asyncio.to_thread(dump_path.stat)).st_size if await asyncio.to_thread(dump_path.exists) else 0
return {
'type': 'sqlite',
'path': dump_path.name,
@@ -516,9 +520,9 @@ class BackupService:
]
logger.info('📦 Экспорт PostgreSQL через pg_dump ...', pg_dump_path=pg_dump_path)
dump_path.parent.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(lambda: dump_path.parent.mkdir(parents=True, exist_ok=True))
with dump_path.open('wb') as dump_file:
with open(dump_path, 'wb') as dump_file:
process = await asyncio.create_subprocess_exec(
*command,
stdout=dump_file,
@@ -558,7 +562,7 @@ class BackupService:
async with aiofiles.open(dump_path, 'w', encoding='utf-8') as dump_file:
await dump_file.write(json_lib.dumps(dump_structure, ensure_ascii=False, indent=2))
size = dump_path.stat().st_size if dump_path.exists() else 0
size = (await asyncio.to_thread(dump_path.stat)).st_size if await asyncio.to_thread(dump_path.exists) else 0
logger.info('✅ PostgreSQL экспортирован через ORM в JSON', dump_path=dump_path)
@@ -575,10 +579,10 @@ class BackupService:
async def _dump_sqlite(self, dump_path: Path):
sqlite_path = Path(settings.SQLITE_PATH)
if not sqlite_path.exists():
if not await asyncio.to_thread(sqlite_path.exists):
raise FileNotFoundError(f'SQLite база данных не найдена по пути {sqlite_path}')
dump_path.parent.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(lambda: dump_path.parent.mkdir(parents=True, exist_ok=True))
await asyncio.to_thread(shutil.copy2, sqlite_path, dump_path)
logger.info('✅ SQLite база данных скопирована', dump_path=dump_path)
@@ -662,11 +666,11 @@ class BackupService:
async def _collect_files(self, staging_dir: Path, include_logs: bool) -> list[dict[str, Any]]:
files_info: list[dict[str, Any]] = []
files_dir = staging_dir / 'files'
files_dir.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(lambda: files_dir.mkdir(parents=True, exist_ok=True))
if include_logs and settings.LOG_FILE:
log_path = Path(settings.LOG_FILE)
if log_path.exists():
if await asyncio.to_thread(log_path.exists):
dest = files_dir / log_path.name
await asyncio.to_thread(shutil.copy2, log_path, dest)
files_info.append(
@@ -676,8 +680,8 @@ class BackupService:
}
)
if not files_info and files_dir.exists():
files_dir.rmdir()
if not files_info and await asyncio.to_thread(files_dir.exists):
await asyncio.to_thread(files_dir.rmdir)
return files_info
@@ -688,7 +692,7 @@ class BackupService:
'items': 0,
}
if not self.data_dir.exists():
if not await asyncio.to_thread(self.data_dir.exists):
return snapshot_info
counter = {'items': 0}
@@ -732,7 +736,7 @@ class BackupService:
tar.extractall(temp_path, filter='data')
metadata_path = temp_path / 'metadata.json'
if not metadata_path.exists():
if not await asyncio.to_thread(metadata_path.exists):
return False, '❌ Метаданные бекапа отсутствуют'
async with aiofiles.open(metadata_path, encoding='utf-8') as meta_file:
@@ -758,7 +762,7 @@ class BackupService:
await self._restore_sqlite(dump_file, clear_existing)
data_dir = temp_path / 'data'
if data_dir.exists():
if await asyncio.to_thread(data_dir.exists):
await self._restore_data_snapshot(data_dir, clear_existing)
if files_info:
@@ -775,7 +779,7 @@ class BackupService:
return True, message
async def _restore_postgres(self, dump_path: Path, clear_existing: bool):
if not dump_path.exists():
if not await asyncio.to_thread(dump_path.exists):
raise FileNotFoundError(f'Dump PostgreSQL не найден: {dump_path}')
psql_path = self._resolve_command_path('psql', 'PSQL_PATH')
@@ -833,7 +837,7 @@ class BackupService:
logger.info('✅ PostgreSQL восстановлен', dump_path=dump_path)
async def _restore_postgres_json(self, dump_path: Path, clear_existing: bool):
if not dump_path.exists():
if not await asyncio.to_thread(dump_path.exists):
raise FileNotFoundError(f'JSON дамп PostgreSQL не найден: {dump_path}')
async with aiofiles.open(dump_path, encoding='utf-8') as dump_file:
@@ -853,20 +857,20 @@ class BackupService:
logger.info('✅ PostgreSQL восстановлен из ORM JSON', dump_path=dump_path)
async def _restore_sqlite(self, dump_path: Path, clear_existing: bool):
if not dump_path.exists():
if not await asyncio.to_thread(dump_path.exists):
raise FileNotFoundError(f'SQLite файл не найден: {dump_path}')
target_path = Path(settings.SQLITE_PATH)
target_path.parent.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(lambda: target_path.parent.mkdir(parents=True, exist_ok=True))
if clear_existing and target_path.exists():
target_path.unlink()
if clear_existing and await asyncio.to_thread(target_path.exists):
await asyncio.to_thread(target_path.unlink)
await asyncio.to_thread(shutil.copy2, dump_path, target_path)
logger.info('✅ SQLite база восстановлена', target_path=target_path)
async def _restore_data_snapshot(self, source_dir: Path, clear_existing: bool):
if not source_dir.exists():
if not await asyncio.to_thread(source_dir.exists):
return
def _restore():
@@ -891,7 +895,7 @@ class BackupService:
logger.info('📁 Снимок директории data восстановлен')
async def _restore_files(self, files_info: list[dict[str, Any]], temp_path: Path):
allowed_base = self.data_dir.resolve()
allowed_base = await asyncio.to_thread(self.data_dir.resolve)
for file_info in files_info:
relative_path = file_info.get('relative_path')
@@ -899,21 +903,22 @@ class BackupService:
if not relative_path or not target_path:
continue
target_resolved = target_path.resolve()
target_resolved = await asyncio.to_thread(target_path.resolve)
if not str(target_resolved).startswith(str(allowed_base) + os.sep) and target_resolved != allowed_base:
logger.warning('Заблокирована запись за пределами data_dir', target_path=target_path)
continue
source_file = (temp_path / relative_path).resolve()
if not str(source_file).startswith(str(temp_path.resolve()) + os.sep):
source_file = await asyncio.to_thread((temp_path / relative_path).resolve)
temp_path_resolved = await asyncio.to_thread(temp_path.resolve)
if not str(source_file).startswith(str(temp_path_resolved) + os.sep):
logger.warning('Path traversal в relative_path', relative_path=relative_path)
continue
if not source_file.exists():
if not await asyncio.to_thread(source_file.exists):
logger.warning('Файл отсутствует в архиве', relative_path=relative_path)
continue
target_resolved.parent.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(lambda: target_resolved.parent.mkdir(parents=True, exist_ok=True))
await asyncio.to_thread(shutil.copy2, source_file, target_resolved)
logger.info('📁 Файл восстановлен', target_resolved=target_resolved)
@@ -1573,8 +1578,10 @@ class BackupService:
backups = []
try:
for backup_file in sorted(self.backup_dir.glob('backup_*'), reverse=True):
if not backup_file.is_file():
for backup_file in sorted(
await asyncio.to_thread(lambda: list(self.backup_dir.glob('backup_*'))), reverse=True
):
if not await asyncio.to_thread(backup_file.is_file):
continue
try:
@@ -1598,7 +1605,7 @@ class BackupService:
backup_structure = json_lib.load(f)
metadata = backup_structure.get('metadata', {})
file_stats = backup_file.stat()
file_stats = await asyncio.to_thread(backup_file.stat)
backup_info = {
'filename': backup_file.name,
@@ -1626,7 +1633,7 @@ class BackupService:
except Exception as e:
logger.error('Ошибка чтения метаданных', backup_file=backup_file, error=e)
file_stats = backup_file.stat()
file_stats = await asyncio.to_thread(backup_file.stat)
backups.append(
{
'filename': backup_file.name,
@@ -1651,14 +1658,15 @@ class BackupService:
async def delete_backup(self, backup_filename: str) -> tuple[bool, str]:
try:
backup_path = (self.backup_dir / backup_filename).resolve()
if not str(backup_path).startswith(str(self.backup_dir.resolve()) + os.sep):
backup_path = await asyncio.to_thread((self.backup_dir / backup_filename).resolve)
backup_dir_resolved = await asyncio.to_thread(self.backup_dir.resolve)
if not str(backup_path).startswith(str(backup_dir_resolved) + os.sep):
return False, '❌ Недопустимое имя файла бекапа'
if not backup_path.is_file():
if not await asyncio.to_thread(backup_path.is_file):
return False, f'❌ Файл бекапа не найден: {backup_filename}'
backup_path.unlink()
await asyncio.to_thread(backup_path.unlink)
message = f'✅ Бекап {backup_filename} удален'
logger.info(message)
@@ -1826,9 +1834,9 @@ class BackupService:
await self.bot.send_document(**send_kwargs)
logger.info('Бекап отправлен в чат', chat_id=chat_id)
if temp_zip_path and Path(temp_zip_path).exists():
if temp_zip_path and await asyncio.to_thread(Path(temp_zip_path).exists):
try:
Path(temp_zip_path).unlink()
await asyncio.to_thread(Path(temp_zip_path).unlink)
except Exception as cleanup_error:
logger.warning('Не удалось удалить временный архив', cleanup_error=cleanup_error)
@@ -1838,7 +1846,7 @@ class BackupService:
async def _create_password_protected_archive(self, file_path: str, password: str) -> str | None:
try:
source_path = Path(file_path)
if not source_path.exists():
if not await asyncio.to_thread(source_path.exists):
logger.error('Исходный файл бекапа не найден', file_path=file_path)
return None
+4 -4
View File
@@ -1,9 +1,9 @@
"""Enum classes for contest system."""
from enum import Enum
from enum import StrEnum
class GameType(str, Enum):
class GameType(StrEnum):
"""Types of daily contest games."""
QUEST_BUTTONS = 'quest_buttons'
@@ -30,14 +30,14 @@ class GameType(str, Enum):
}
class RoundStatus(str, Enum):
class RoundStatus(StrEnum):
"""Contest round status."""
ACTIVE = 'active'
FINISHED = 'finished'
class PrizeType(str, Enum):
class PrizeType(StrEnum):
"""Types of prizes for contests."""
DAYS = 'days'
+141 -8
View File
@@ -17,7 +17,17 @@ from app.config import settings
from app.database.crud.landing import create_guest_purchase
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, replace_subscription
from app.database.crud.tariff import get_tariff_by_id
from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage, Tariff, User
from app.database.crud.transaction import create_transaction
from app.database.crud.user import _get_or_create_default_promo_group
from app.database.models import (
GuestPurchase,
GuestPurchaseStatus,
LandingPage,
PaymentMethod,
Tariff,
TransactionType,
User,
)
from app.services.subscription_service import SubscriptionService
@@ -242,7 +252,7 @@ async def fulfill_purchase(
# Active subscription or gift with any existing subscription — hold for manual activation
purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
purchase.user_id = user.id
if recipient_type == 'email' and not purchase.is_gift:
if recipient_type == 'email' and not purchase.is_gift and is_new_account:
purchase.auto_login_token = create_auto_login_token(user.id)
await db.commit()
await db.refresh(purchase, attribute_names=['landing', 'user'])
@@ -310,12 +320,28 @@ async def fulfill_purchase(
purchase.status = GuestPurchaseStatus.DELIVERED.value
purchase.user_id = user.id
purchase.delivered_at = datetime.now(UTC)
if recipient_type == 'email' and not purchase.is_gift:
if recipient_type == 'email' and not purchase.is_gift and is_new_account:
purchase.auto_login_token = create_auto_login_token(user.id)
await db.commit()
await db.refresh(purchase, attribute_names=['landing', 'user'])
# Create transaction so promo group auto-assignment and contest tracking work
try:
payment_method_enum = _resolve_payment_method(purchase.payment_method)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=purchase.amount_kopeks,
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
payment_method=payment_method_enum,
external_id=purchase.payment_id,
is_completed=True,
)
except Exception:
logger.exception('Failed to create transaction for guest purchase', purchase_id=purchase.id)
try:
await send_guest_notification(
purchase,
@@ -357,6 +383,26 @@ async def fulfill_purchase(
return purchase
def _resolve_payment_method(method_str: str | None) -> PaymentMethod | None:
"""Convert payment method string from GuestPurchase to PaymentMethod enum."""
if not method_str:
return None
# Try exact match first (handles 'telegram_stars', 'kassa_ai', 'yookassa', etc.)
try:
return PaymentMethod(method_str)
except ValueError:
pass
# Strip sub-option suffix ('yookassa_sbp' → 'yookassa', 'platega_2' → 'platega')
if '_' in method_str:
base_method = method_str.split('_')[0]
try:
return PaymentMethod(base_method)
except ValueError:
pass
logger.debug('Unknown payment method for transaction', method=method_str)
return None
def _mask_email(email: str) -> str:
"""Mask email for logging: 'user@example.com' -> 'u***@e***.com'."""
if not email:
@@ -399,8 +445,8 @@ async def _find_or_create_user(
user = result.scalars().first()
if user:
is_new_account = False
# Existing user WITHOUT password — generate one and set up cabinet access
if not user.password_hash:
# User without cabinet access — generate credentials
plain_password = secrets.token_urlsafe(12)
user.password_hash = hash_password(plain_password)
if purchase:
@@ -410,16 +456,22 @@ async def _find_or_create_user(
if not user.email_verified:
user.email_verified = True
user.email_verified_at = datetime.now(UTC)
# Ensure default promo group
if not user.promo_group_id:
default_group = await _get_or_create_default_promo_group(db)
user.promo_group_id = default_group.id
return user, is_new_account
# Create new email user with verified cabinet account
plain_password = secrets.token_urlsafe(12)
default_group = await _get_or_create_default_promo_group(db)
user = User(
auth_type='email',
email=contact_value,
email_verified=True,
email_verified_at=datetime.now(UTC),
password_hash=hash_password(plain_password),
promo_group_id=default_group.id,
)
if purchase:
purchase.cabinet_password = plain_password
@@ -431,7 +483,7 @@ async def _find_or_create_user(
result = await db.execute(select(User).where(User.email == contact_value))
user = result.scalars().first()
if user:
# Clear stale password from failed insert, then check if re-fetched user needs one
# Race condition — user was created concurrently
if purchase:
purchase.cabinet_password = None
is_new_account = False
@@ -444,6 +496,9 @@ async def _find_or_create_user(
if not user.email_verified:
user.email_verified = True
user.email_verified_at = datetime.now(UTC)
if not user.promo_group_id:
default_group = await _get_or_create_default_promo_group(db)
user.promo_group_id = default_group.id
return user, is_new_account
raise
logger.info(
@@ -508,13 +563,19 @@ async def _find_or_create_user(
resolved_telegram_id=resolved_telegram_id,
)
await db.refresh(user)
# Ensure default promo group
if not user.promo_group_id:
default_group = await _get_or_create_default_promo_group(db)
user.promo_group_id = default_group.id
return user, False
# Create new telegram user
default_group = await _get_or_create_default_promo_group(db)
user = User(
auth_type='telegram',
username=username,
telegram_id=resolved_telegram_id,
promo_group_id=default_group.id,
)
try:
async with db.begin_nested():
@@ -525,10 +586,16 @@ async def _find_or_create_user(
result = await db.execute(select(User).where(User.telegram_id == resolved_telegram_id))
user = result.scalars().first()
if user:
if not user.promo_group_id:
default_group = await _get_or_create_default_promo_group(db)
user.promo_group_id = default_group.id
return user, False
result = await db.execute(select(User).where(func.lower(User.username) == normalized))
user = result.scalars().first()
if user:
if not user.promo_group_id:
default_group = await _get_or_create_default_promo_group(db)
user.promo_group_id = default_group.id
return user, False
raise
logger.info(
@@ -831,10 +898,9 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
connected_squads=tariff.allowed_squads or [],
is_trial=False,
update_server_counters=True,
commit=False,
)
subscription.tariff_id = tariff.id
await db.commit()
await db.refresh(subscription, ['tariff'])
else:
subscription = await create_paid_subscription(
db=db,
@@ -845,6 +911,7 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
connected_squads=tariff.allowed_squads or [],
tariff_id=tariff.id,
update_server_counters=True,
commit=False,
)
await subscription_service.create_remnawave_user(db, subscription)
@@ -854,11 +921,29 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
purchase.subscription_crypto_link = subscription.subscription_crypto_link
purchase.status = GuestPurchaseStatus.DELIVERED.value
purchase.delivered_at = datetime.now(UTC)
if user.auth_type == 'email' and not purchase.is_gift:
if user.auth_type == 'email' and not purchase.is_gift and is_new_account:
purchase.auto_login_token = create_auto_login_token(user.id)
# Single atomic commit: subscription + purchase status + user changes
await db.commit()
await db.refresh(purchase, attribute_names=['landing', 'user'])
# Create transaction so promo group auto-assignment and contest tracking work
try:
payment_method_enum = _resolve_payment_method(purchase.payment_method)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=purchase.amount_kopeks,
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
payment_method=payment_method_enum,
external_id=purchase.payment_id,
is_completed=True,
)
except Exception:
logger.exception('Failed to create transaction for activated purchase', purchase_id=purchase.id)
if not skip_notification:
try:
await send_guest_notification(
@@ -923,6 +1008,8 @@ async def retry_stuck_paid_purchases(
GuestPurchase.status == GuestPurchaseStatus.PAID.value,
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
# Exclude code-only gifts — they stay PAID intentionally until activated
~(GuestPurchase.is_gift.is_(True) & GuestPurchase.gift_recipient_type.is_(None)),
)
.order_by(GuestPurchase.paid_at.asc().nulls_first())
.limit(limit)
@@ -943,3 +1030,49 @@ async def retry_stuck_paid_purchases(
logger.exception('Failed to retry stuck purchase', token_prefix=token[:5])
return retried
async def retry_stuck_pending_activation(
db: AsyncSession,
stale_minutes: int = 10,
limit: int = 10,
max_age_hours: int = 24,
) -> int:
"""Retry activation for purchases stuck in PENDING_ACTIVATION status.
This handles the case where activate_purchase() failed after the status
was already transitioned to PENDING_ACTIVATION (e.g., Remnawave panel was
temporarily down). Each retry runs in an isolated session.
"""
from app.database.database import AsyncSessionLocal
cutoff = datetime.now(UTC) - timedelta(minutes=stale_minutes)
max_age = datetime.now(UTC) - timedelta(hours=max_age_hours)
result = await db.execute(
select(GuestPurchase.token)
.where(
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
GuestPurchase.user_id.isnot(None),
)
.order_by(GuestPurchase.paid_at.asc().nulls_first())
.limit(limit)
)
tokens = result.scalars().all()
if not tokens:
return 0
retried = 0
for token in tokens:
try:
async with AsyncSessionLocal() as retry_db:
await activate_purchase(retry_db, token)
retried += 1
logger.info('Retried stuck pending_activation successfully', token_prefix=token[:5])
except Exception:
logger.exception('Failed to retry stuck pending_activation', token_prefix=token[:5])
return retried
+9 -9
View File
@@ -78,8 +78,8 @@ class LogRotationService:
async def initialize(self) -> None:
"""Создать необходимые директории."""
self.current_dir.mkdir(parents=True, exist_ok=True)
self.archive_dir.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(lambda: self.current_dir.mkdir(parents=True, exist_ok=True))
await asyncio.to_thread(lambda: self.archive_dir.mkdir(parents=True, exist_ok=True))
async def start(self) -> None:
"""Запустить сервис ротации."""
@@ -170,7 +170,7 @@ class LogRotationService:
# Собираем файлы для архивации
files_to_archive: list[tuple[Path, str]] = []
for name, log_path in self.log_files.items():
if log_path.exists() and log_path.stat().st_size > 0:
if await asyncio.to_thread(log_path.exists) and (await asyncio.to_thread(log_path.stat)).st_size > 0:
files_to_archive.append((log_path, f'{name}.log'))
if not files_to_archive:
@@ -184,7 +184,7 @@ class LogRotationService:
if archive_path:
# Очищаем текущие лог-файлы
for log_path, _ in files_to_archive:
log_path.write_text('')
await asyncio.to_thread(log_path.write_text, '')
# Очистка старых архивов
await self._cleanup_old_archives()
@@ -247,12 +247,12 @@ class LogRotationService:
keep_days = settings.LOG_ROTATION_KEEP_DAYS
cutoff_date = datetime.now(get_local_timezone()) - timedelta(days=keep_days)
if not self.archive_dir.exists():
if not await asyncio.to_thread(self.archive_dir.exists):
return
# Ищем файлы вида logs_YYYY-MM-DD.tar.gz или logs_YYYY-MM-DD.tar
for archive_file in self.archive_dir.iterdir():
if not archive_file.is_file():
for archive_file in await asyncio.to_thread(lambda: list(self.archive_dir.iterdir())):
if not await asyncio.to_thread(archive_file.is_file):
continue
# Извлекаем дату из имени файла logs_YYYY-MM-DD.tar.gz
@@ -267,7 +267,7 @@ class LogRotationService:
file_date = file_date.replace(tzinfo=get_local_timezone())
if file_date < cutoff_date:
archive_file.unlink()
await asyncio.to_thread(archive_file.unlink)
logger.info('Удален старый архив логов', archive_file_name=archive_file.name)
except ValueError:
# Пропускаем файлы с некорректным форматом имени
@@ -287,7 +287,7 @@ class LogRotationService:
topic_id = settings.get_log_rotation_topic_id()
try:
file_size_kb = archive_path.stat().st_size / 1024
file_size_kb = (await asyncio.to_thread(archive_path.stat)).st_size / 1024
caption = (
f'<b>Логи бота</b>\n'
f'Дата: {date_str}\n'
+85 -21
View File
@@ -111,7 +111,11 @@ class MonitoringService:
logger.debug('Пропуск уведомления: пользователь недоступен', user_id=user.id, status=user.status)
return None
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and not caption_exceeds_telegram_limit(text):
if (
settings.ENABLE_LOGO_MODE
and await asyncio.to_thread(LOGO_PATH.exists)
and not caption_exceeds_telegram_limit(text)
):
try:
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
@@ -221,6 +225,18 @@ class MonitoringService:
# экспайрятся до того, как autopay успеет их продлить
if settings.ENABLE_AUTOPAY:
await self._process_autopayments(db)
# Рекуррентные автоплатежи: пополнение баланса с сохранённой карты
if settings.YOOKASSA_RECURRENT_ENABLED:
try:
from app.services.recurrent_payment_service import process_recurrent_payments
await process_recurrent_payments(db=db, bot=self.bot)
except Exception as recurrent_error:
logger.error(
'Ошибка рекуррентных автоплатежей',
error=recurrent_error,
exc_info=True,
)
await self._check_expired_subscriptions(db)
await self._check_expiring_subscriptions(db)
await self._check_trial_expiring_soon(db)
@@ -410,6 +426,15 @@ class MonitoringService:
expiring_subscriptions = await self._get_expiring_paid_subscriptions(db, days)
sent_count = 0
# Batch-запрос: собираем user_id с autopay и проверяем наличие карт одним запросом
users_with_cards: set[int] = set()
if settings.ENABLE_AUTOPAY and settings.YOOKASSA_RECURRENT_ENABLED:
autopay_user_ids = [s.user_id for s in expiring_subscriptions if s.autopay_enabled]
if autopay_user_ids:
from app.database.crud.saved_payment_method import get_user_ids_with_active_payment_methods
users_with_cards = await get_user_ids_with_active_payment_methods(db, autopay_user_ids)
for subscription in expiring_subscriptions:
user = await get_user_by_id(db, subscription.user_id)
if not user:
@@ -430,6 +455,8 @@ class MonitoringService:
)
continue
has_saved_card = subscription.autopay_enabled and user.id in users_with_cards
should_send = True
for other_days in warning_days:
if other_days < days:
@@ -466,7 +493,9 @@ class MonitoringService:
continue
if self.bot:
success = await self._send_subscription_expiring_notification(user, subscription, days)
success = await self._send_subscription_expiring_notification(
user, subscription, days, has_saved_card=has_saved_card
)
if success:
await record_notification(db, user.id, subscription.id, 'expiring', days)
all_processed_users.add(user_key)
@@ -1260,7 +1289,9 @@ class MonitoringService:
)
return False
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool:
async def _send_subscription_expiring_notification(
self, user: User, subscription: Subscription, days: int, *, has_saved_card: bool = False
) -> bool:
try:
from app.utils.formatters import format_days_declension
@@ -1268,27 +1299,56 @@ class MonitoringService:
days_text = format_days_declension(days, user.language)
if settings.ENABLE_AUTOPAY:
if subscription.autopay_enabled:
autopay_status = '✅ Включен - подписка продлится автоматически'
action_text = (
f'💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}'
if subscription.autopay_enabled and has_saved_card:
autopay_status = texts.t(
'AUTOPAY_STATUS_CARD_ACTIVE',
'✅ Включен — будет автоматическое списание с карты',
)
action_text = texts.t(
'AUTOPAY_ACTION_CHECK_BALANCE',
'💰 Убедитесь, что на балансе достаточно средств: {balance}',
).format(balance=texts.format_price(user.balance_kopeks))
elif subscription.autopay_enabled:
autopay_status = texts.t(
'AUTOPAY_STATUS_NO_CARD',
'✅ Включен — подписка продлится автоматически',
)
action_text = texts.t(
'AUTOPAY_ACTION_CHECK_BALANCE',
'💰 Убедитесь, что на балансе достаточно средств: {balance}',
).format(balance=texts.format_price(user.balance_kopeks))
else:
autopay_status = '❌ Отключен - не забудьте продлить вручную!'
action_text = '💡 Включите автоплатеж или продлите подписку вручную'
autopay_status = texts.t(
'AUTOPAY_STATUS_OFF',
'❌ Отключен — не забудьте продлить вручную!',
)
action_text = texts.t(
'AUTOPAY_ACTION_ENABLE',
'💡 Включите автоплатеж или продлите подписку вручную',
)
else:
autopay_status = '❌ Отключен - не забудьте продлить вручную!'
action_text = '💡 Продлите подписку вручную'
autopay_status = texts.t(
'AUTOPAY_STATUS_OFF',
'❌ Отключен — не забудьте продлить вручную!',
)
action_text = texts.t(
'AUTOPAY_ACTION_RENEW',
'💡 Продлите подписку вручную',
)
message = f"""
<b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}.
💳 <b>Автоплатеж:</b> {autopay_status}
{action_text}
"""
end_date = format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')
message = texts.t(
'SUBSCRIPTION_EXPIRING_PAID',
'\n⚠️ <b>Подписка истекает через {days_text}!</b>\n\n'
'Ваша платная подписка истекает {end_date}.\n\n'
'💳 <b>Автоплатеж:</b> {autopay_status}\n\n'
'{action_text}\n',
).format(
days_text=days_text,
end_date=end_date,
autopay_status=autopay_status,
action_text=action_text,
)
from aiogram.types import InlineKeyboardMarkup
@@ -1682,11 +1742,15 @@ class MonitoringService:
async def _retry_stuck_guest_purchases(self, db: AsyncSession):
try:
from app.services.guest_purchase_service import retry_stuck_paid_purchases
from app.services.guest_purchase_service import retry_stuck_paid_purchases, retry_stuck_pending_activation
retried = await retry_stuck_paid_purchases(db, stale_minutes=5, limit=10)
if retried:
logger.info('Retried stuck guest purchases', retried=retried)
retried_pa = await retry_stuck_pending_activation(db, stale_minutes=10, limit=10)
if retried_pa:
logger.info('Retried stuck pending_activation purchases', retried=retried_pa)
except Exception:
logger.error('Error retrying stuck guest purchases', exc_info=True)
+31 -5
View File
@@ -9,6 +9,7 @@ import structlog
from sqlalchemy import and_, case, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.transaction import REAL_PAYMENT_METHODS
from app.database.models import (
AdvertisingCampaignRegistration,
ReferralEarning,
@@ -938,11 +939,18 @@ class PartnerStatsService:
registrations_dict = {str(row.date): int(row.count) for row in registrations_by_day.all()}
# --- Daily revenue (DAILY_STATS_DAYS days) ---
# Revenue = deposits (positive) + abs(subscription_payments) (stored negative)
# Revenue = real deposits (positive) + abs(subscription_payments) (stored negative)
# Exclude promo/bonus deposits (payment_method IS NULL) from revenue
revenue_amount_expr = func.coalesce(
func.sum(
case(
(Transaction.type == TransactionType.DEPOSIT.value, Transaction.amount_kopeks),
(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
),
Transaction.amount_kopeks,
),
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
func.abs(Transaction.amount_kopeks),
@@ -1070,7 +1078,13 @@ class PartnerStatsService:
func.coalesce(
func.sum(
case(
(Transaction.type == TransactionType.DEPOSIT.value, Transaction.amount_kopeks),
(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
),
Transaction.amount_kopeks,
),
else_=0,
)
),
@@ -1117,7 +1131,13 @@ class PartnerStatsService:
func.coalesce(
func.sum(
case(
(Transaction.type == TransactionType.DEPOSIT.value, Transaction.amount_kopeks),
(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
),
Transaction.amount_kopeks,
),
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
func.abs(Transaction.amount_kopeks),
@@ -1149,7 +1169,13 @@ class PartnerStatsService:
func.coalesce(
func.sum(
case(
(Transaction.type == TransactionType.DEPOSIT.value, Transaction.amount_kopeks),
(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
),
Transaction.amount_kopeks,
),
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
func.abs(Transaction.amount_kopeks),
+2
View File
@@ -13,6 +13,7 @@ from .kassa_ai import KassaAiPaymentMixin
from .mulenpay import MulenPayPaymentMixin
from .pal24 import Pal24PaymentMixin
from .platega import PlategaPaymentMixin
from .riopay import RioPayPaymentMixin
from .stars import TelegramStarsMixin
from .tribute import TributePaymentMixin
from .wata import WataPaymentMixin
@@ -29,6 +30,7 @@ __all__ = [
'Pal24PaymentMixin',
'PaymentCommonMixin',
'PlategaPaymentMixin',
'RioPayPaymentMixin',
'TelegramStarsMixin',
'TributePaymentMixin',
'WataPaymentMixin',
+11
View File
@@ -521,6 +521,17 @@ async def try_fulfill_guest_purchase(
paid_at=datetime.now(UTC),
)
# Code-only gifts (is_gift=True, no recipient) stay in PAID status
# — buyer shares the code manually, recipient activates via cabinet/bot
if existing and existing.is_gift and not existing.gift_recipient_type:
await db.commit()
logger.info(
'Code-only gift marked as PAID, skipping fulfillment',
purchase_token_prefix=purchase_token[:5],
provider=provider_name,
)
return True
# Fulfill: create user, subscription, deliver (commits on success)
await fulfill_purchase(db, purchase_token)
+507
View File
@@ -0,0 +1,507 @@
"""Mixin для интеграции с RioPay (api.riopay.online)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.riopay import (
create_riopay_payment as crud_create_riopay_payment,
get_riopay_payment_by_order_id,
get_riopay_payment_by_riopay_order_id,
update_riopay_payment_status,
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import get_user_by_id
from app.database.models import PaymentMethod, TransactionType, User as UserModel
from app.services.riopay_service import riopay_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг статусов RioPay → internal
RIOPAY_STATUS_MAP = {
'COMPLETED': ('success', True),
'CANCELED': ('canceled', False),
'FAILED': ('failed', False),
'EXPIRED': ('expired', False),
'CREATED': ('pending', False),
'PENDING': ('pending', False),
}
class RioPayPaymentMixin:
"""Mixin для работы с платежами RioPay."""
async def create_riopay_payment(
self,
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
) -> dict[str, Any] | None:
"""
Создает платеж RioPay.
Returns:
Словарь с данными платежа или None при ошибке
"""
if not settings.is_riopay_enabled():
logger.error('RioPay не настроен')
return None
# Валидация лимитов
if amount_kopeks < settings.RIOPAY_MIN_AMOUNT_KOPEKS:
logger.warning(
'RioPay: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
RIOPAY_MIN_AMOUNT_KOPEKS=settings.RIOPAY_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.RIOPAY_MAX_AMOUNT_KOPEKS:
logger.warning(
'RioPay: сумма больше максимальной',
amount_kopeks=amount_kopeks,
RIOPAY_MAX_AMOUNT_KOPEKS=settings.RIOPAY_MAX_AMOUNT_KOPEKS,
)
return None
# Получаем telegram_id пользователя для order_id
user = await get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
# Генерируем уникальный order_id с telegram_id для удобного поиска
order_id = f'rp{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.RIOPAY_CURRENCY
# Срок действия платежа (1 час по умолчанию)
expires_at = datetime.now(UTC) + timedelta(hours=1)
# Метаданные
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
}
try:
# Используем API для создания заказа
result = await riopay_service.create_order(
amount=amount_rubles,
currency=currency,
external_id=order_id,
purpose=description,
success_url=settings.RIOPAY_SUCCESS_URL,
fail_url=settings.RIOPAY_FAIL_URL,
)
payment_url = result.get('paymentLink')
riopay_order_id = result.get('id')
if not payment_url:
logger.error('RioPay API не вернул URL платежа', result=result)
return None
logger.info(
'RioPay API: создан заказ', order_id=order_id, riopay_order_id=riopay_order_id, payment_url=payment_url
)
# Сохраняем в БД
local_payment = await crud_create_riopay_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
riopay_order_id=riopay_order_id,
payment_method=result.get('paymentType'),
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'RioPay: создан платеж',
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
)
return {
'order_id': order_id,
'riopay_order_id': riopay_order_id,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'payment_url': payment_url,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('RioPay: ошибка создания платежа', e=e)
return None
async def process_riopay_webhook(
self,
db: AsyncSession,
*,
payload: dict[str, Any],
) -> bool:
"""
Обрабатывает webhook от RioPay.
Подпись проверяется в webserver/payments.py до вызова этого метода.
Args:
db: Сессия БД
payload: JSON тело webhook
Returns:
True если платеж успешно обработан
"""
try:
# Извлекаем данные из payload
riopay_order_id = payload.get('id')
external_id = payload.get('externalId')
riopay_status = payload.get('status')
amount = payload.get('amount')
if not riopay_order_id or not riopay_status:
logger.warning('RioPay webhook: отсутствуют обязательные поля', payload=payload)
return False
# Ищем платеж по external_id (наш order_id) или riopay_order_id
payment = None
if external_id:
payment = await get_riopay_payment_by_order_id(db, external_id)
if not payment and riopay_order_id:
payment = await get_riopay_payment_by_riopay_order_id(db, riopay_order_id)
if not payment:
logger.warning(
'RioPay webhook: платеж не найден',
external_id=external_id,
riopay_order_id=riopay_order_id,
)
return False
# Проверка дублирования
if payment.is_paid:
logger.info('RioPay webhook: платеж уже обработан', order_id=payment.order_id)
return True
# Маппинг статуса
status_info = RIOPAY_STATUS_MAP.get(riopay_status, ('pending', False))
internal_status, is_paid = status_info
callback_payload = {
'riopay_order_id': riopay_order_id,
'external_id': external_id,
'status': riopay_status,
'amount': amount,
'payment_type': payload.get('paymentType'),
'included_fee': payload.get('includedFee'),
}
# Проверка суммы ДО обновления статуса
if is_paid and amount is not None:
expected = payment.amount_kopeks / 100
if abs(float(amount) - expected) > 0.01:
logger.error(
'RioPay amount mismatch',
expected=expected,
received=amount,
order_id=payment.order_id,
)
await update_riopay_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
riopay_order_id=riopay_order_id,
payment_method=payload.get('paymentType'),
callback_payload=callback_payload,
)
return False
# Обновляем статус платежа только после проверки суммы
payment = await update_riopay_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=is_paid,
riopay_order_id=riopay_order_id,
payment_method=payload.get('paymentType'),
callback_payload=callback_payload,
)
# Финализируем платеж если оплачен
if is_paid:
return await self._finalize_riopay_payment(
db, payment, riopay_order_id=riopay_order_id, trigger='webhook'
)
return True
except Exception as e:
logger.exception('RioPay webhook: ошибка обработки', e=e)
return False
async def _finalize_riopay_payment(
self,
db: AsyncSession,
payment: Any,
*,
riopay_order_id: str | None,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
if payment.transaction_id:
logger.info('RioPay платеж уже привязан к транзакции', order_id=payment.order_id, trigger=trigger)
return True
# Получаем пользователя
user = await get_user_by_id(db, payment.user_id)
if not user:
logger.error(
'Пользователь не найден для RioPay платежа',
user_id=payment.user_id,
order_id=payment.order_id,
trigger=trigger,
)
return False
# Создаем транзакцию
transaction = await create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f'Пополнение через RioPay (#{riopay_order_id or payment.order_id})',
payment_method=PaymentMethod.RIOPAY,
external_id=str(riopay_order_id) if riopay_order_id else payment.order_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
)
# Связываем платеж с транзакцией
await update_riopay_payment_status(
db=db,
payment=payment,
status=payment.status,
transaction_id=transaction.id,
)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
# Атомарное начисление баланса через SQL UPDATE, чтобы избежать race condition
# при одновременных вебхуках / check_riopay_payment_status
update_values: dict[str, Any] = {
UserModel.balance_kopeks: UserModel.balance_kopeks + payment.amount_kopeks,
UserModel.updated_at: datetime.now(UTC),
}
if was_first_topup:
update_values[UserModel.has_made_first_topup] = True
await db.execute(update(UserModel).where(UserModel.id == user.id).values(update_values))
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
topup_status = 'Первое пополнение' if was_first_topup else 'Пополнение'
await db.commit()
# Обработка реферального пополнения
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(db, user.id, payment.amount_kopeks, getattr(self, 'bot', None))
except Exception as error:
logger.error('Ошибка обработки реферального пополнения RioPay', error=error)
await db.refresh(user)
await db.refresh(payment)
# Отправка уведомления админам
if getattr(self, 'bot', None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления RioPay', error=error)
# Отправка уведомления пользователю (только Telegram-пользователям)
if getattr(self, 'bot', None) and user.telegram_id:
try:
display_name = settings.get_riopay_display_name()
keyboard = await self.build_topup_success_keyboard(user)
message = (
'✅ <b>Пополнение успешно!</b>\n\n'
f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
f'💳 Способ: {display_name}\n'
f'🆔 Транзакция: {transaction.id}\n\n'
'Баланс пополнен автоматически!'
)
await self.bot.send_message(
user.telegram_id,
message,
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.error('Ошибка отправки уведомления пользователю RioPay', error=error)
# Автопокупка подписки и уведомление о корзине
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
except Exception as error:
logger.error(
'Ошибка при работе с сохраненной корзиной для пользователя', user_id=user.id, error=error, exc_info=True
)
logger.info(
'Обработан RioPay платеж',
order_id=payment.order_id,
user_id=payment.user_id,
trigger=trigger,
)
return True
async def check_riopay_payment_status(
self,
db: AsyncSession,
order_id: str,
) -> dict[str, Any] | None:
"""
Проверяет статус платежа через API.
"""
try:
payment = await get_riopay_payment_by_order_id(db, order_id)
if not payment:
logger.warning('RioPay payment not found', order_id=order_id)
return None
if payment.is_paid:
return {
'payment': payment,
'status': 'success',
'is_paid': True,
}
# Проверяем через API по riopay_order_id (UUID)
if payment.riopay_order_id:
try:
order_data = await riopay_service.get_order(payment.riopay_order_id)
riopay_status = order_data.get('status')
if riopay_status:
status_info = RIOPAY_STATUS_MAP.get(riopay_status, ('pending', False))
internal_status, is_paid = status_info
if is_paid:
# Проверка суммы ДО обновления статуса
api_amount = order_data.get('amount')
if api_amount is not None:
expected = payment.amount_kopeks / 100
if abs(float(api_amount) - expected) > 0.01:
logger.error(
'RioPay amount mismatch (API check)',
expected=expected,
received=api_amount,
order_id=payment.order_id,
)
await update_riopay_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
riopay_order_id=payment.riopay_order_id,
callback_payload={
'check_source': 'api',
'riopay_order_data': order_data,
},
)
return {
'payment': payment,
'status': 'amount_mismatch',
'is_paid': False,
}
logger.info('RioPay payment confirmed via API', order_id=payment.order_id)
callback_payload = {
'check_source': 'api',
'riopay_order_data': order_data,
}
payment = await update_riopay_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
riopay_order_id=payment.riopay_order_id,
payment_method=order_data.get('paymentType'),
callback_payload=callback_payload,
)
await self._finalize_riopay_payment(
db,
payment,
riopay_order_id=payment.riopay_order_id,
trigger='api_check',
)
elif internal_status != payment.status:
# Обновляем статус если изменился
payment = await update_riopay_payment_status(
db=db,
payment=payment,
status=internal_status,
)
except Exception as e:
logger.error('Error checking RioPay payment status via API', e=e)
return {
'payment': payment,
'status': payment.status or 'pending',
'is_paid': payment.is_paid,
}
except Exception as e:
logger.exception('RioPay: ошибка проверки статуса', e=e)
return None
+1
View File
@@ -345,6 +345,7 @@ class TelegramStarsMixin:
transaction,
period_display,
was_trial_conversion=False,
purchase_type='renewal' if user.has_had_paid_subscription else 'first_purchase',
)
except Exception as admin_error: # pragma: no cover - диагностический лог
logger.error(
+120 -30
View File
@@ -386,6 +386,7 @@ class YooKassaPaymentMixin:
self,
db: AsyncSession,
payment: YooKassaPayment,
event_object: dict[str, Any] | None = None,
) -> bool:
"""Переносит успешный платёж YooKassa в транзакции и начисляет баланс пользователю."""
try:
@@ -590,6 +591,7 @@ class YooKassaPaymentMixin:
payment_type = payment_metadata.get('type', '')
is_simple_subscription = payment_purpose == 'simple_subscription_purchase'
is_trial_payment = payment_type == 'trial'
is_recurrent_topup = payment_metadata.get('purpose') == 'recurrent_topup'
transaction_type = (
TransactionType.SUBSCRIPTION_PAYMENT
@@ -767,7 +769,7 @@ class YooKassaPaymentMixin:
)
# Используем full_user для форматирования реферальной информации, чтобы избежать проблем с ленивой загрузкой
user_for_referrer = full_user if full_user else user
user_for_referrer = full_user or user
referrer_info = format_referrer_info(user_for_referrer)
topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение'
@@ -825,36 +827,38 @@ class YooKassaPaymentMixin:
'Ошибка отправки уведомления админам о YooKassa пополнении', error=error, exc_info=True
)
# Отправляем уведомление пользователю (только Telegram-пользователям)
if getattr(self, 'bot', None) and user.telegram_id:
# Для рекуррентных автоплатежей уведомления отправляет recurrent_payment_service
if not is_recurrent_topup:
# Отправляем уведомление пользователю (только Telegram-пользователям)
if getattr(self, 'bot', None) and user.telegram_id:
try:
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
await self._send_payment_success_notification(
user.telegram_id,
payment.amount_kopeks,
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
db=db,
payment_method_title='Банковская карта (YooKassa)',
)
logger.info('Уведомление пользователю о платеже отправлено успешно')
except Exception as error:
logger.error('Ошибка отправки уведомления о платеже', error=error, exc_info=True)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
try:
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
await self._send_payment_success_notification(
user.telegram_id,
payment.amount_kopeks,
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
db=db,
payment_method_title='Банковская карта (YooKassa)',
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(
user, payment.amount_kopeks, db, getattr(self, 'bot', None)
)
except Exception as e:
logger.error(
'Ошибка при работе с сохраненной корзиной для пользователя',
user_id=user.id,
error=e,
exc_info=True,
)
logger.info('Уведомление пользователю о платеже отправлено успешно')
except Exception as error:
logger.error('Ошибка отправки уведомления о платеже', error=error, exc_info=True)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(
user, payment.amount_kopeks, db, getattr(self, 'bot', None)
)
except Exception as e:
logger.error(
'Ошибка при работе с сохраненной корзиной для пользователя',
user_id=user.id,
error=e,
exc_info=True,
)
if is_simple_subscription:
logger.info('Обнаружен платеж простой покупки подписки для пользователя', user_id=user.id)
@@ -972,6 +976,9 @@ class YooKassaPaymentMixin:
transaction,
subscription_period,
was_trial_conversion=False,
purchase_type='renewal'
if (full_user or user).has_had_paid_subscription
else 'first_purchase',
)
except Exception as admin_error:
logger.error(
@@ -1026,6 +1033,10 @@ class YooKassaPaymentMixin:
amount_rubles=payment.amount_kopeks / 100,
)
# Сохраняем привязанный метод оплаты для рекуррентных платежей
if settings.YOOKASSA_RECURRENT_ENABLED and event_object:
await self._save_payment_method_if_available(db, payment, event_object)
# Создаем чек через NaloGO (если NALOGO_ENABLED=true)
if hasattr(self, 'nalogo_service') and self.nalogo_service:
await self._create_nalogo_receipt(
@@ -1086,6 +1097,85 @@ class YooKassaPaymentMixin:
return updated_metadata
async def _save_payment_method_if_available(
self,
db: AsyncSession,
payment: YooKassaPayment,
event_object: dict[str, Any],
) -> None:
"""Сохраняет привязанный метод оплаты из ответа YooKassa, если карта была сохранена."""
try:
pm = event_object.get('payment_method') or {}
pm_id = pm.get('id')
pm_saved = pm.get('saved', False)
if not pm_id or not pm_saved:
return
from app.database.crud.saved_payment_method import (
create_saved_payment_method,
get_payment_method_by_yookassa_id,
)
# Проверяем, не сохранён ли уже (включая деактивированные —
# если пользователь удалил карту, не реактивируем её)
existing = await get_payment_method_by_yookassa_id(db, pm_id, include_inactive=True)
if existing:
logger.debug(
'Метод оплаты уже сохранён',
yookassa_payment_method_id=pm_id,
user_id=payment.user_id,
is_active=existing.is_active,
)
return
# Извлекаем данные карты
card = pm.get('card') or {}
card_first6 = card.get('first6')
card_last4 = card.get('last4')
card_type = card.get('card_type')
raw_month = card.get('expiry_month')
raw_year = card.get('expiry_year')
expiry_month = str(raw_month).zfill(2) if raw_month is not None else None
expiry_year = str(raw_year) if raw_year is not None else None
method_type = pm.get('type', 'bank_card')
# Формируем название
title = None
if card_last4:
type_label = card_type or 'Card'
title = f'{type_label} *{card_last4}'
saved = await create_saved_payment_method(
db=db,
user_id=payment.user_id,
yookassa_payment_method_id=pm_id,
method_type=method_type,
card_first6=card_first6,
card_last4=card_last4,
card_type=card_type,
card_expiry_month=expiry_month,
card_expiry_year=expiry_year,
title=title,
)
if saved:
logger.info(
'Метод оплаты сохранён для рекуррентных платежей',
saved_method_id=saved.id,
user_id=payment.user_id,
card_last4=card_last4,
method_type=method_type,
)
except Exception as save_error:
logger.error(
'Ошибка сохранения метода оплаты',
yookassa_payment_id=payment.yookassa_payment_id,
save_error=save_error,
exc_info=True,
)
async def _create_nalogo_receipt(
self,
db: AsyncSession,
@@ -1223,7 +1313,7 @@ class YooKassaPaymentMixin:
await db.refresh(payment)
if payment.status == 'succeeded' and payment.is_paid:
return await self._process_successful_yookassa_payment(db, payment)
return await self._process_successful_yookassa_payment(db, payment, event_object=event_object)
logger.info(
'Webhook YooKassa обновил платеж до статуса',
@@ -128,6 +128,13 @@ def _get_method_defaults() -> dict:
'default_max': settings.KASSA_AI_MAX_AMOUNT_KOPEKS,
'available_sub_options': None,
},
'riopay': {
'default_display_name': settings.get_riopay_display_name(),
'is_configured': settings.is_riopay_enabled(),
'default_min': settings.RIOPAY_MIN_AMOUNT_KOPEKS,
'default_max': settings.RIOPAY_MAX_AMOUNT_KOPEKS,
'available_sub_options': None,
},
}
@@ -147,7 +154,7 @@ def _get_platega_sub_options() -> list[dict] | None:
'name': info.get('title') or info.get('name') or f'Platega {method_code}',
}
)
return options if options else None
return options or None
except Exception:
return None
@@ -168,6 +175,7 @@ DEFAULT_METHOD_ORDER = [
'freekassa_card',
'cloudpayments',
'kassa_ai',
'riopay',
]
+49
View File
@@ -33,6 +33,7 @@ from app.services.payment import (
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
from app.services.payment.freekassa import FreekassaPaymentMixin
from app.services.payment.kassa_ai import KassaAiPaymentMixin
from app.services.payment.riopay import RioPayPaymentMixin
from app.services.platega_service import PlategaService
from app.services.wata_service import WataService
from app.services.yookassa_service import YooKassaService
@@ -316,6 +317,7 @@ class PaymentService(
CloudPaymentsPaymentMixin,
FreekassaPaymentMixin,
KassaAiPaymentMixin,
RioPayPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
@@ -684,6 +686,53 @@ class PaymentService(
}
return None
# --- Telegram Stars ---------------------------------------------------
if payment_method == 'telegram_stars':
if not settings.TELEGRAM_STARS_ENABLED:
logger.warning('Telegram Stars is not enabled, cannot create guest payment')
return None
if self.bot is None:
logger.warning('Bot instance required for Stars guest payment')
return None
from aiogram.types import LabeledPrice
rate = settings.get_stars_rate()
if rate <= 0:
logger.error('TELEGRAM_STARS_RATE_RUB is not positive, cannot create Stars invoice')
return None
amount_rubles = amount_kopeks / 100
stars_amount = max(1, round(amount_rubles / rate))
payload = f'guest_purchase_{purchase_token}'
try:
invoice_url = await self.bot.create_invoice_link(
title='Подарочная подписка VPN',
description=f'{description} ({stars_amount} ⭐)',
payload=payload,
provider_token='',
currency='XTR',
prices=[LabeledPrice(label='Подарочная подписка', amount=stars_amount)],
)
logger.info(
'Created Stars invoice for guest purchase',
stars_amount=stars_amount,
purchase_token_prefix=purchase_token[:5],
)
return {
'payment_url': invoice_url,
'payment_id': f'stars_{purchase_token[:12]}',
'provider': 'telegram_stars',
}
except Exception as stars_error:
logger.error('Error creating Stars invoice for guest payment', error=stars_error)
return None
# --- Unsupported provider ---------------------------------------------
logger.warning(
'Guest payment requested for unsupported provider',
+391
View File
@@ -0,0 +1,391 @@
"""Сервис рекуррентных автоплатежей через YooKassa.
Находит подписки с autopay, у которых недостаточно баланса для продления,
и пополняет баланс с сохранённой карты. Существующий autopay в
monitoring_service затем спишет баланс и продлит подписку.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.models import (
Subscription,
SubscriptionStatus,
User,
UserPromoGroup,
)
logger = structlog.get_logger(__name__)
@dataclass
class _DailyGuard:
"""Защита от повторной обработки подписок в рамках одного дня."""
date: str = ''
processed: set[str] = field(default_factory=set)
def reset_if_new_day(self) -> None:
today = datetime.now(UTC).strftime('%Y-%m-%d')
if today != self.date:
self.processed = set()
self.date = today
def is_processed(self, key: str) -> bool:
return key in self.processed
def mark_processed(self, key: str) -> None:
self.processed.add(key)
_daily_guard = _DailyGuard()
def _build_extend_keyboard(texts) -> InlineKeyboardMarkup:
"""Клавиатура с кнопкой продления подписки для уведомлений."""
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('SUBSCRIPTION_EXTEND', '💎 Продлить подписку'),
callback_data='subscription_extend',
)
],
]
)
async def process_recurrent_payments(db: AsyncSession, bot: Bot | None = None) -> dict:
"""
Основная функция: находит подписки, которым скоро нужно продление,
у которых недостаточно баланса, и пополняет баланс с сохранённой карты.
Args:
db: Сессия БД из вызывающего кода (_monitoring_cycle)
bot: Экземпляр бота для уведомлений
Returns:
dict: Статистика обработки
"""
if not settings.YOOKASSA_RECURRENT_ENABLED:
return {'skipped': True, 'reason': 'recurrent_disabled'}
if not settings.YOOKASSA_ENABLED:
return {'skipped': True, 'reason': 'yookassa_disabled'}
if not settings.ENABLE_AUTOPAY:
return {'skipped': True, 'reason': 'autopay_disabled'}
_daily_guard.reset_if_new_day()
stats = {
'checked': 0,
'payments_created': 0,
'insufficient_no_card': 0,
'all_cards_failed': 0,
'already_processed': 0,
'errors': 0,
}
# Создаём сервисы один раз для всех подписок
from app.services.payment_service import PaymentService
from app.services.subscription_service import SubscriptionService
payment_service = PaymentService()
subscription_service = SubscriptionService()
try:
subscriptions = await _find_subscriptions_needing_topup(db)
stats['checked'] = len(subscriptions)
for subscription in subscriptions:
user = subscription.user
if not user:
continue
guard_key = f'{user.id}_{subscription.id}'
if _daily_guard.is_processed(guard_key):
stats['already_processed'] += 1
continue
try:
result = await _process_single_subscription(
db,
subscription,
user,
bot,
payment_service,
subscription_service,
)
if result == 'created':
stats['payments_created'] += 1
_daily_guard.mark_processed(guard_key)
elif result == 'no_card':
stats['insufficient_no_card'] += 1
_daily_guard.mark_processed(guard_key)
elif result == 'all_cards_failed':
stats['all_cards_failed'] += 1
_daily_guard.mark_processed(guard_key)
elif result == 'skipped':
stats['already_processed'] += 1
except Exception as e:
stats['errors'] += 1
logger.error(
'Ошибка обработки рекуррентного платежа',
subscription_id=subscription.id,
user_id=user.id,
error=e,
exc_info=True,
)
except Exception as e:
logger.error('Ошибка получения подписок для рекуррентных платежей', error=e, exc_info=True)
stats['errors'] += 1
if stats['payments_created'] > 0 or stats['errors'] > 0:
logger.info('Рекуррентные платежи: итоги', **stats)
return stats
async def _find_subscriptions_needing_topup(db: AsyncSession) -> list:
"""Находит подписки с autopay, которым скоро нужно продление."""
current_time = datetime.now(UTC)
max_days_before = settings.DEFAULT_AUTOPAY_DAYS_BEFORE
# Максимальный горизонт проверки
check_horizon = current_time + timedelta(days=max_days_before + 1)
recently_expired_threshold = current_time - timedelta(hours=48)
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user).options(
selectinload(User.promo_group),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
),
selectinload(Subscription.tariff),
)
.where(
and_(
or_(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date <= check_horizon,
),
and_(
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date >= recently_expired_threshold,
),
),
Subscription.autopay_enabled == True,
Subscription.is_trial == False,
)
)
)
return list(result.scalars().all())
async def _process_single_subscription(
db: AsyncSession,
subscription: Subscription,
user: User,
bot: Bot | None,
payment_service,
subscription_service,
) -> str:
"""
Обрабатывает одну подписку: проверяет баланс, находит карту, создаёт автоплатёж.
Returns:
'created' автоплатёж создан
'no_card' нет сохранённой карты
'all_cards_failed' все карты не сработали
'skipped' баланс достаточен или другая причина пропуска
"""
from app.database.crud.saved_payment_method import get_active_payment_methods_by_user
# Рассчитываем стоимость продления
tariff = getattr(subscription, 'tariff', None)
if tariff:
autopay_period = tariff.get_shortest_period() or 30
else:
autopay_period = 30
try:
renewal_cost = await subscription_service.calculate_renewal_price(
subscription,
autopay_period,
db,
user=user,
)
except Exception as e:
logger.error(
'Ошибка расчёта стоимости для рекуррентного платежа',
subscription_id=subscription.id,
user_id=user.id,
error=e,
)
return 'skipped'
if renewal_cost <= 0:
return 'skipped'
# Проверяем, хватает ли баланса
shortage = renewal_cost - user.balance_kopeks
if shortage <= 0:
# Баланса достаточно, обычный autopay справится
return 'skipped'
# Используем autopay_days_before конкретной подписки, если задан
days_before = getattr(subscription, 'autopay_days_before', None) or settings.DEFAULT_AUTOPAY_DAYS_BEFORE
days_until_expiry = (subscription.end_date - datetime.now(UTC)).total_seconds() / 86400
if days_until_expiry > days_before and subscription.status != SubscriptionStatus.EXPIRED.value:
return 'skipped'
# Нужно пополнить баланс — ищем сохранённую карту
saved_methods = await get_active_payment_methods_by_user(db, user.id)
if not saved_methods:
return 'no_card'
# Сумма пополнения = нехватка (минимум YOOKASSA_MIN_AMOUNT_KOPEKS)
min_amount = settings.YOOKASSA_MIN_AMOUNT_KOPEKS
topup_amount_kopeks = max(shortage, min_amount)
topup_amount_rubles = topup_amount_kopeks / 100
# Создаём автоплатёж
yookassa_service = payment_service.yookassa_service
if not yookassa_service or not yookassa_service.configured:
logger.warning('YooKassa сервис не сконфигурирован для рекуррентных платежей')
return 'skipped'
description = settings.get_balance_payment_description(topup_amount_kopeks)
metadata = {
'user_id': str(user.id),
'user_telegram_id': str(user.telegram_id) if user.telegram_id else '',
'purpose': 'recurrent_topup',
'subscription_id': str(subscription.id),
'source': 'recurrent_payment_service',
}
# Перебираем все сохранённые карты пока не найдём рабочую
today = datetime.now(UTC).strftime('%Y-%m-%d')
for saved_method in saved_methods:
# Детерминированный ключ: при рестарте/повторе YooKassa вернёт тот же платёж
idem_key = f'recurrent_{subscription.id}_{saved_method.id}_{today}'
result = await yookassa_service.create_autopayment(
amount=topup_amount_rubles,
currency='RUB',
description=description,
payment_method_id=saved_method.yookassa_payment_method_id,
metadata=metadata,
idempotence_key=idem_key,
)
if not result:
card_display = f'*{saved_method.card_last4}' if saved_method.card_last4 else ''
logger.warning(
'Не удалось списать с карты, пробуем следующую',
user_id=user.id,
subscription_id=subscription.id,
payment_method_id=saved_method.yookassa_payment_method_id,
card_display=card_display,
)
continue
# Успешно — сохраняем локальную запись с привязкой к YooKassa ID
try:
from app.database.crud.yookassa import create_yookassa_payment
yookassa_created_at = None
if result.get('created_at'):
try:
yookassa_created_at = datetime.fromisoformat(result['created_at'].replace('Z', '+00:00'))
except Exception:
pass
result_payment = await create_yookassa_payment(
db=db,
user_id=user.id,
yookassa_payment_id=result['id'],
amount_kopeks=topup_amount_kopeks,
currency='RUB',
description=description,
status=result.get('status', 'pending'),
metadata_json=metadata,
yookassa_created_at=yookassa_created_at,
test_mode=result.get('test_mode', False),
)
if result_payment:
logger.info(
'Рекуррентный автоплатёж создан',
user_id=user.id,
subscription_id=subscription.id,
amount_kopeks=topup_amount_kopeks,
yookassa_payment_id=result['id'],
)
except Exception as e:
logger.warning('Ошибка создания локальной записи рекуррентного платежа', error=e)
# Уведомляем пользователя
if bot and user.telegram_id:
try:
from app.localization.texts import get_texts
texts = get_texts(user.language)
payment_status = result.get('status', '')
if result.get('paid'):
keyboard = _build_extend_keyboard(texts)
msg = texts.t(
'RECURRENT_TOPUP_SUCCESS',
'✅ <b>Автоплатёж выполнен</b>\n\nБаланс пополнен на {amount} для продления подписки.',
).format(amount=settings.format_price(topup_amount_kopeks))
await bot.send_message(
chat_id=user.telegram_id,
text=msg,
parse_mode='HTML',
reply_markup=keyboard,
)
elif payment_status == 'pending':
logger.info(
'Рекуррентный платёж в обработке',
user_id=user.id,
yookassa_payment_id=result.get('id'),
)
except Exception as notify_error:
logger.warning('Ошибка уведомления об автоплатеже', notify_error=notify_error)
return 'created'
# Все карты не сработали — уведомляем пользователя
if bot and user.telegram_id:
try:
from app.localization.texts import get_texts
texts = get_texts(user.language)
keyboard = _build_extend_keyboard(texts)
msg = texts.t(
'RECURRENT_TOPUP_FAILED',
'❌ <b>Автоплатёж не удался</b>\n\nНе удалось списать {amount} ни с одной сохранённой карты для продления подписки.\n\nПополните баланс вручную, чтобы подписка не прервалась.',
).format(amount=settings.format_price(topup_amount_kopeks))
await bot.send_message(
chat_id=user.telegram_id,
text=msg,
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as notify_error:
logger.warning('Ошибка уведомления о неудачном автоплатеже', notify_error=notify_error)
return 'all_cards_failed'
+39 -7
View File
@@ -4,7 +4,7 @@ from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.referral import create_referral_earning, get_user_campaign_id
from app.database.crud.referral import create_referral_earning, get_commission_payment_count, get_user_campaign_id
from app.database.crud.user import add_user_balance, get_user_by_id
from app.database.models import ReferralEarning, TransactionType, User
from app.services.notification_delivery_service import (
@@ -16,6 +16,23 @@ from app.utils.user_utils import get_effective_referral_commission_percent
logger = structlog.get_logger(__name__)
async def _is_commission_limit_reached(db: AsyncSession, referrer_id: int, referral_id: int) -> bool:
"""Проверяет, исчерпан ли лимит комиссионных платежей для пары реферер-реферал."""
if settings.REFERRAL_MAX_COMMISSION_PAYMENTS <= 0:
return False
paid_count = await get_commission_payment_count(db, referrer_id, referral_id)
if paid_count >= settings.REFERRAL_MAX_COMMISSION_PAYMENTS:
logger.info(
'Лимит комиссионных платежей исчерпан',
referrer_id=referrer_id,
referral_id=referral_id,
paid_count=paid_count,
max_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
)
return True
return False
async def send_referral_notification(
bot: Bot,
telegram_id: int | None,
@@ -99,19 +116,28 @@ async def process_referral_registration(db: AsyncSession, new_user_id: int, refe
commission_percent = get_effective_referral_commission_percent(referrer)
referral_notification = (
f'🎉 <b>Добро пожаловать!</b>\n\n'
f'Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!\n\n'
f'💰 При первом пополнении от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} '
f'вы получите бонус {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n'
# f"🎁 Ваш реферер также получит награду за ваше первое пополнение."
f'Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!'
)
if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
referral_notification += (
f'\n\n💰 При первом пополнении от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} '
f'вы получите бонус {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!'
)
await send_referral_notification(bot, new_user.telegram_id, referral_notification, user=new_user)
inviter_notification = (
f'👥 <b>Новый реферал!</b>\n\n'
f'По вашей ссылке зарегистрировался пользователь <b>{new_user.full_name}</b>!\n\n'
f'💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, '
f'вы получите минимум {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)} или '
f'{commission_percent}% от суммы (что больше).\n\n'
)
if settings.REFERRAL_INVITER_BONUS_KOPEKS > 0:
inviter_notification += (
f'вы получите минимум {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)} или '
f'{commission_percent}% от суммы (что больше).\n\n'
)
else:
inviter_notification += f'вы получите {commission_percent}% от суммы.\n\n'
inviter_notification += (
f'📈 С каждого последующего пополнения вы будете получать {commission_percent}% комиссии.'
)
await send_referral_notification(
@@ -169,6 +195,9 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
topup_amount_kopeks=topup_amount_kopeks / 100,
)
if commission_amount > 0 and await _is_commission_limit_reached(db, referrer.id, user.id):
return True
if commission_amount > 0:
balance_ok = await add_user_balance(
db,
@@ -325,6 +354,9 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
)
elif commission_amount > 0:
if await _is_commission_limit_reached(db, referrer.id, user.id):
return True
balance_ok = await add_user_balance(
db,
referrer,
+5 -2
View File
@@ -278,7 +278,8 @@ class ReferralWithdrawalService:
if referral_ids:
month_ago = datetime.now(UTC) - timedelta(days=30)
# Одним запросом получаем статистику пополнений всех рефералов за месяц
# Одним запросом получаем статистику реальных пополнений всех рефералов за месяц
# (исключаем промо-бонусы с payment_method=NULL)
ref_deposits_result = await db.execute(
select(
Transaction.user_id,
@@ -290,6 +291,7 @@ class ReferralWithdrawalService:
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= month_ago,
Transaction.payment_method.isnot(None),
)
.group_by(Transaction.user_id)
)
@@ -328,7 +330,7 @@ class ReferralWithdrawalService:
if suspicious_referrals:
analysis['flags'].append(f'⚠️ Подозрительная активность у {len(suspicious_referrals)} реферала(ов)')
# Общая статистика по рефералам (за всё время)
# Общая статистика по рефералам (за всё время, только реальные платежи)
all_ref_deposits = await db.execute(
select(
func.count(func.distinct(Transaction.user_id)).label('paying_count'),
@@ -338,6 +340,7 @@ class ReferralWithdrawalService:
Transaction.user_id.in_(referral_ids),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.payment_method.isnot(None),
)
)
ref_stats = all_ref_deposits.fetchone()
+16 -26
View File
@@ -1759,34 +1759,24 @@ class RemnaWaveService:
# Конвертируем локальную дату из БД в UTC для корректного сравнения
local_end_date_utc = self._local_to_utc(subscription.end_date)
# КРИТИЧНО: НЕ перезаписываем end_date если локальная дата ПОЗЖЕ
# Это защищает от ситуации когда подписка была продлена в боте,
# но RemnaWave ещё не получил обновление или вернул старую дату
# Панель авторитетна для ACTIVE подписок — обновляем end_date
# в обоих направлениях (как вперёд, так и назад)
time_diff = abs((local_end_date_utc - expire_at).total_seconds())
if time_diff > 60:
if expire_at > local_end_date_utc:
# RemnaWave имеет более позднюю дату - обновляем
# Конвертируем UTC обратно в локальное время для сохранения в БД
new_end_date_local = expire_at.replace(tzinfo=self._utc_timezone).astimezone(
self._panel_timezone
)
logger.info(
'✅ Sync: обновлена end_date для user -> (разница: с)',
value=getattr(user, 'telegram_id', '?'),
end_date=subscription.end_date,
new_end_date_local=new_end_date_local,
time_diff=round(time_diff, 0),
)
subscription.end_date = new_end_date_local
else:
# Локальная дата позже - НЕ перезаписываем
logger.debug(
'⏭️ Sync: end_date для user актуальна: локальная ( UTC: ) RemnaWave ( UTC)',
value=getattr(user, 'telegram_id', '?'),
end_date=subscription.end_date,
local_end_date_utc=local_end_date_utc,
expire_at=expire_at,
)
# Конвертируем UTC обратно в локальное время для сохранения в БД
new_end_date_local = expire_at.replace(tzinfo=self._utc_timezone).astimezone(
self._panel_timezone
)
direction = '' if expire_at > local_end_date_utc else ''
logger.info(
'✅ Sync: обновлена end_date для user -> (разница: с, направление: )',
value=getattr(user, 'telegram_id', '?'),
end_date=subscription.end_date,
new_end_date_local=new_end_date_local,
time_diff=round(time_diff, 0),
direction=direction,
)
subscription.end_date = new_end_date_local
else:
logger.debug(
'⏭️ Sync: пропускаем обновление end_date для user разница слишком мала (с < 60с)',
+9 -10
View File
@@ -600,23 +600,22 @@ class RemnaWaveWebhookService:
except (ValueError, TypeError):
pass
# Sync expire date (only if panel date is LATER than local to prevent race condition
# where webhook with stale expireAt overwrites a freshly extended subscription)
# Sync expire date — panel is the source of truth for user.modified events
expire_at = data.get('expireAt')
if expire_at:
try:
parsed_dt = datetime.fromisoformat(expire_at.replace('Z', '+00:00'))
new_end_date = parsed_dt.astimezone(UTC)
if subscription.end_date != new_end_date:
if not subscription.end_date or new_end_date > subscription.end_date:
subscription.end_date = new_end_date
changed = True
else:
logger.warning(
'Webhook: пропуск перезаписи end_date — локальная дата позже',
old_end_date = subscription.end_date
subscription.end_date = new_end_date
changed = True
if old_end_date and new_end_date < old_end_date:
logger.info(
'Webhook: end_date обновлена назад (панель авторитетна): → ',
subscription_id=subscription.id,
local_end_date=subscription.end_date,
webhook_end_date=new_end_date,
old_end_date=old_end_date,
new_end_date=new_end_date,
)
except (ValueError, TypeError):
pass
+169
View File
@@ -0,0 +1,169 @@
"""Сервис для работы с API RioPay (api.riopay.online) v2.0.1."""
import hashlib
import hmac
from typing import Any
import aiohttp
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
API_BASE_URL = 'https://api.riopay.online'
class RioPayAPIError(Exception):
"""Ошибка API RioPay."""
def __init__(self, status_code: int, message: str):
self.status_code = status_code
self.message = message
super().__init__(f'RioPay API error ({status_code}): {message}')
class RioPayService:
"""Сервис для работы с API RioPay."""
def __init__(self):
self._api_token: str | None = None
self._session: aiohttp.ClientSession | None = None
@property
def api_token(self) -> str:
if self._api_token is None:
self._api_token = settings.RIOPAY_API_TOKEN
return self._api_token or ''
@property
def webhook_secret(self) -> str:
"""Ключ для HMAC-SHA512 верификации вебхуков. По умолчанию = api_token."""
return settings.RIOPAY_WEBHOOK_SECRET or self.api_token
def _get_headers(self) -> dict[str, str]:
"""Формирует заголовки для API запросов."""
return {
'x-api-token': self.api_token,
'Content-Type': 'application/json',
}
async def _get_session(self) -> aiohttp.ClientSession:
"""Возвращает переиспользуемую HTTP-сессию."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
)
return self._session
async def close(self) -> None:
"""Закрывает HTTP-сессию."""
if self._session and not self._session.closed:
await self._session.close()
self._session = None
async def create_order(
self,
*,
amount: float,
currency: str = 'RUB',
external_id: str,
purpose: str = 'Пополнение баланса',
success_url: str | None = None,
fail_url: str | None = None,
) -> dict[str, Any]:
"""
Создает заказ через API RioPay.
POST /v1/orders
Returns:
OrderData dict с полями id, status, paymentLink, amount, currency, etc.
"""
payload: dict[str, Any] = {
'amount': str(amount),
'currency': currency,
'externalId': external_id,
'purpose': purpose,
}
if success_url:
payload['successUrl'] = success_url
if fail_url:
payload['failUrl'] = fail_url
logger.info(
'RioPay API create_order',
external_id=external_id,
amount=amount,
currency=currency,
)
try:
session = await self._get_session()
async with session.post(
f'{API_BASE_URL}/v1/orders',
json=payload,
headers=self._get_headers(),
) as response:
if response.status == 201:
data = await response.json(content_type=None)
logger.info('RioPay API order created', status_code=response.status, order_id=data.get('id'))
return data
# Ошибка
text = await response.text()
try:
error_data = await response.json(content_type=None)
error_msg = error_data.get('message') or error_data.get('error') or text
except Exception:
error_msg = text
logger.error('RioPay create_order error', status_code=response.status)
logger.debug('RioPay create_order error details', error_msg=error_msg)
raise RioPayAPIError(response.status, error_msg)
except aiohttp.ClientError as e:
logger.exception('RioPay API connection error', error=e)
raise
async def get_order(self, order_id: str) -> dict[str, Any]:
"""
Получает заказ по UUID.
GET /v1/orders/{id}
"""
logger.info('RioPay get_order', order_id=order_id)
try:
session = await self._get_session()
async with session.get(
f'{API_BASE_URL}/v1/orders/{order_id}',
headers=self._get_headers(),
) as response:
if response.status == 200:
return await response.json(content_type=None)
text = await response.text()
logger.error('RioPay get_order error', status_code=response.status)
raise RioPayAPIError(response.status, text)
except aiohttp.ClientError as e:
logger.exception('RioPay API connection error', error=e)
raise
def verify_webhook_signature(self, raw_body: bytes, signature: str) -> bool:
"""HMAC-SHA512 верификация подписи webhook."""
try:
expected = hmac.new(
self.webhook_secret.encode(),
raw_body,
hashlib.sha512,
).hexdigest()
return hmac.compare_digest(expected, signature)
except Exception as e:
logger.error('RioPay webhook verify error', error=e)
return False
# Singleton instance
riopay_service = RioPayService()
@@ -825,7 +825,13 @@ async def _auto_purchase_tariff(
await with_admin_notification_service(
lambda svc: svc.send_subscription_purchase_notification(
db, user, subscription, transaction, period_days, was_trial_conversion
db,
user,
subscription,
transaction,
period_days,
was_trial_conversion,
purchase_type='renewal',
)
)
except Exception as error:
@@ -1100,7 +1106,13 @@ async def _auto_purchase_daily_tariff(
await with_admin_notification_service(
lambda svc: svc.send_subscription_purchase_notification(
db, user, subscription, transaction, 1, was_trial_conversion
db,
user,
subscription,
transaction,
1,
was_trial_conversion,
purchase_type='renewal',
)
)
except Exception as error:
@@ -1290,6 +1302,7 @@ async def _auto_add_devices(
description,
create_transaction=True,
payment_method=PaymentMethod.BALANCE,
transaction_type=TransactionType.SUBSCRIPTION_PAYMENT,
)
if not success:
logger.warning(
@@ -1348,7 +1361,7 @@ async def _auto_add_devices(
await db.rollback()
return False
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
from app.database.crud.subscription import reactivate_subscription
await reactivate_subscription(db, subscription)
@@ -1357,6 +1370,9 @@ async def _auto_add_devices(
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
except Exception as error:
logger.warning(
'⚠️ Автопокупка устройств: не удалось обновить Remnawave для пользователя',
@@ -1528,6 +1544,7 @@ async def _auto_add_traffic(
description,
create_transaction=True,
payment_method=PaymentMethod.BALANCE,
transaction_type=TransactionType.SUBSCRIPTION_PAYMENT,
)
if not success:
logger.warning(
@@ -1559,7 +1576,7 @@ async def _auto_add_traffic(
await db.rollback()
return False
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
from app.database.crud.subscription import reactivate_subscription
await reactivate_subscription(db, subscription)
@@ -1568,6 +1585,9 @@ async def _auto_add_traffic(
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
except Exception as error:
logger.warning(
'⚠️ Автопокупка трафика: не удалось обновить Remnawave для пользователя',
@@ -2398,6 +2418,7 @@ async def auto_purchase_saved_cart_after_topup(
transaction,
selection.period.days,
was_trial_conversion,
purchase_type='renewal',
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
@@ -527,7 +527,7 @@ class MiniAppSubscriptionPurchaseService:
value = int(package.get('gb') or 0)
price_per_month = int(package.get('price') or 0)
discounted_per_month, discount_value = _apply_percentage_discount(price_per_month, discount_percent)
label = texts.format_traffic(value if value else 0)
label = texts.format_traffic(value or 0)
options.append(
PurchaseTrafficOption(
value=value,
@@ -587,7 +587,7 @@ class MiniAppSubscriptionPurchaseService:
options=options,
min_selectable=1 if options else 0,
max_selectable=len(options),
default_selection=default_selection if default_selection else [opt.uuid for opt in options[:1]],
default_selection=default_selection or [opt.uuid for opt in options[:1]],
hint=None,
)
+14 -18
View File
@@ -14,7 +14,6 @@ from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Us
from app.external.remnawave_api import RemnaWaveAPI, RemnaWaveAPIError, RemnaWaveUser, TrafficLimitStrategy, UserStatus
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
)
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
@@ -1406,8 +1405,9 @@ class SubscriptionService:
if additional_server_ids is None:
additional_server_ids = []
months_to_pay = get_remaining_months(subscription.end_date)
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
now = datetime.now(UTC)
days_to_pay = max(1, (subscription.end_date - now).days)
period_hint_days = days_to_pay
user = getattr(subscription, 'user', None)
promo_group = user.promo_group if user else None
@@ -1424,16 +1424,14 @@ class SubscriptionService:
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
traffic_total_price = discounted_traffic_per_month * months_to_pay
traffic_total_price = int(discounted_traffic_per_month * days_to_pay / 30)
total_price += traffic_total_price
message = (
f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес x {months_to_pay}'
f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес x {days_to_pay} дн.'
f' = {traffic_total_price / 100}'
)
if traffic_discount_per_month > 0:
message += (
f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay / 100}₽)'
)
message += f' (скидка {traffic_discount_percent}%: -{int(traffic_discount_per_month * days_to_pay / 30) / 100}₽)'
logger.info(message)
if additional_devices > 0:
@@ -1446,16 +1444,14 @@ class SubscriptionService:
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
devices_total_price = discounted_devices_per_month * months_to_pay
devices_total_price = int(discounted_devices_per_month * days_to_pay / 30)
total_price += devices_total_price
message = (
f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес x {months_to_pay}'
f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес x {days_to_pay} дн.'
f' = {devices_total_price / 100}'
)
if devices_discount_per_month > 0:
message += (
f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay / 100}₽)'
)
message += f' (скидка {devices_discount_percent}%: -{int(devices_discount_per_month * days_to_pay / 30) / 100}₽)'
logger.info(message)
if additional_server_ids and db:
@@ -1473,20 +1469,20 @@ class SubscriptionService:
)
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
discounted_server_per_month = server_price_per_month - server_discount_per_month
server_total_price = discounted_server_per_month * months_to_pay
server_total_price = int(discounted_server_per_month * days_to_pay / 30)
total_price += server_total_price
message = (
f'Сервер {server.display_name}: {server_price_per_month / 100}₽/мес x {months_to_pay}'
f'Сервер {server.display_name}: {server_price_per_month / 100}₽/мес x {days_to_pay} дн.'
f' = {server_total_price / 100}'
)
if server_discount_per_month > 0:
message += (
f' (скидка {servers_discount_percent}%:'
f' -{server_discount_per_month * months_to_pay / 100}₽)'
f' -{int(server_discount_per_month * days_to_pay / 30) / 100}₽)'
)
logger.info(message)
logger.info('Итого доплата за мес: ₽', months_to_pay=months_to_pay, total_price=total_price / 100)
logger.info('Итого доплата за дн.: ₽', days_to_pay=days_to_pay, total_price=total_price / 100)
return total_price
def _gb_to_bytes(self, gb: int | None) -> int:
@@ -1540,7 +1536,7 @@ class SubscriptionService:
logger.warning('Не удалось предзагрузить тариф подписки', subscription_id=sub.id, error=exc)
# Вычисляем стратегию сброса трафика один раз — все подписки одного тарифа
sample_tariff = subscriptions[0].tariff if subscriptions[0].tariff else None
sample_tariff = subscriptions[0].tariff or None
traffic_strategy = get_traffic_reset_strategy(sample_tariff)
# Параллельная синхронизация: один API-клиент, только HTTP-вызовы внутри gather
+3
View File
@@ -88,6 +88,7 @@ class BotConfigurationService:
'CLOUDPAYMENTS': '💳 CloudPayments',
'FREEKASSA': '💳 Freekassa',
'KASSA_AI': '💳 KassaAI',
'RIOPAY': '💳 RioPay',
'YOOKASSA': '🟣 YooKassa',
'PLATEGA': '💳 {platega_name}',
'TRIBUTE': '🎁 Tribute',
@@ -148,6 +149,7 @@ class BotConfigurationService:
'CLOUDPAYMENTS': 'CloudPayments: оплата банковскими картами, Public ID, API Secret и вебхуки.',
'FREEKASSA': 'Freekassa: ID магазина, API ключ, секретные слова и вебхуки.',
'KASSA_AI': 'KassaAI: отдельная платёжка api.fk.life с СБП, картами и SberPay.',
'RIOPAY': 'RioPay: платёжная система api.riopay.online с поддержкой карт и СБП.',
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
'PAL24': 'PAL24 / PayPalych подключения и лимиты.',
@@ -346,6 +348,7 @@ class BotConfigurationService:
'CLOUDPAYMENTS_': 'CLOUDPAYMENTS',
'FREEKASSA_': 'FREEKASSA',
'KASSA_AI_': 'KASSA_AI',
'RIOPAY_': 'RIOPAY',
'PLATEGA_': 'PLATEGA',
'MULENPAY_': 'MULENPAY',
'PAL24_': 'PAL24',
+123
View File
@@ -1,5 +1,6 @@
import asyncio
import uuid
from datetime import UTC, datetime
from typing import Any
import structlog
@@ -113,6 +114,12 @@ class YooKassaService:
builder.set_receipt(receipt_data_dict)
# Рекуррентные платежи: сохранение карты
if settings.YOOKASSA_RECURRENT_ENABLED:
if settings.YOOKASSA_RECURRENT_REQUIRED:
builder.set_save_payment_method(True)
# Если не required — не устанавливаем, YooKassa покажет чекбокс
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
@@ -308,6 +315,21 @@ class YooKassaService:
'payment_method_type': payment_info_yk.payment_method.type
if payment_info_yk.payment_method
else None,
'payment_method_id': payment_info_yk.payment_method.id if payment_info_yk.payment_method else None,
'payment_method_saved': payment_info_yk.payment_method.saved
if payment_info_yk.payment_method and hasattr(payment_info_yk.payment_method, 'saved')
else False,
'payment_method_card': {
'first6': payment_info_yk.payment_method.card.first6,
'last4': payment_info_yk.payment_method.card.last4,
'card_type': payment_info_yk.payment_method.card.card_type,
'expiry_month': payment_info_yk.payment_method.card.expiry_month,
'expiry_year': payment_info_yk.payment_method.card.expiry_year,
}
if payment_info_yk.payment_method
and hasattr(payment_info_yk.payment_method, 'card')
and payment_info_yk.payment_method.card
else None,
'test_mode': payment_info_yk.test if hasattr(payment_info_yk, 'test') else None,
}
logger.warning('Платеж не найден в YooKassa ID', payment_id_in_yookassa=payment_id_in_yookassa)
@@ -326,3 +348,104 @@ class YooKassaService:
exc_info=True,
)
return None
async def create_autopayment(
self,
amount: float,
currency: str,
description: str,
payment_method_id: str,
metadata: dict[str, Any],
receipt_email: str | None = None,
receipt_phone: str | None = None,
idempotence_key: str | None = None,
) -> dict[str, Any] | None:
"""Создаёт рекуррентный автоплатёж через сохранённый payment_method_id (без confirmation)."""
if not self.configured:
logger.error('YooKassa не сконфигурирован. Невозможно создать автоплатёж.')
return None
customer_contact_for_receipt = {}
if receipt_email:
customer_contact_for_receipt['email'] = receipt_email
elif receipt_phone:
customer_contact_for_receipt['phone'] = receipt_phone
elif hasattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL') and settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
customer_contact_for_receipt['email'] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
else:
logger.error(
'КРИТИЧНО: Не предоставлен email/телефон для чека автоплатежа и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.'
)
return None
try:
builder = PaymentRequestBuilder()
builder.set_amount({'value': str(round(amount, 2)), 'currency': currency.upper()})
builder.set_capture(True)
builder.set_payment_method_id(payment_method_id)
builder.set_description(description)
builder.set_metadata(metadata)
receipt_items_list: list[dict[str, Any]] = [
{
'description': description[:128],
'quantity': '1.00',
'amount': {'value': str(round(amount, 2)), 'currency': currency.upper()},
'vat_code': str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
'payment_mode': getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'),
}
]
receipt_data_dict: dict[str, Any] = {'customer': customer_contact_for_receipt, 'items': receipt_items_list}
builder.set_receipt(receipt_data_dict)
if not idempotence_key:
sub_id = metadata.get('subscription_id', uuid.uuid4())
idempotence_key = f'autopay_{sub_id}_{datetime.now(UTC).strftime("%Y-%m-%d")}'
payment_request = builder.build()
logger.info(
'Создание автоплатежа YooKassa',
amount=amount,
currency=currency,
payment_method_id=payment_method_id,
metadata=metadata,
idempotence_key=idempotence_key,
)
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None, lambda: YooKassaPayment.create(payment_request, idempotence_key)
)
logger.info(
'Ответ YooKassa автоплатёж',
response_id=response.id,
status=response.status,
paid=response.paid,
)
return {
'id': response.id,
'status': response.status,
'paid': response.paid,
'metadata': response.metadata,
'amount_value': float(response.amount.value),
'amount_currency': response.amount.currency,
'idempotence_key_used': idempotence_key,
'refundable': response.refundable,
'created_at': response.created_at.isoformat()
if hasattr(response.created_at, 'isoformat')
else str(response.created_at),
'description_from_yk': response.description,
'test_mode': response.test if hasattr(response, 'test') else None,
}
except Exception as e:
logger.error(
'Ошибка создания автоплатежа YooKassa',
payment_method_id=payment_method_id,
error=e,
exc_info=True,
)
return None
-2
View File
@@ -2,7 +2,6 @@ from .pricing_utils import (
calculate_months_from_days,
calculate_prorated_price,
format_period_description,
get_remaining_months,
)
@@ -10,5 +9,4 @@ __all__ = [
'calculate_months_from_days',
'calculate_prorated_price',
'format_period_description',
'get_remaining_months',
]
+14
View File
@@ -159,6 +159,18 @@ def get_available_payment_methods() -> list[dict[str, str]]:
}
)
if settings.is_riopay_enabled():
riopay_name = settings.get_riopay_display_name()
methods.append(
{
'id': 'riopay',
'name': f'Банковская карта ({riopay_name})',
'icon': '💳',
'description': f'через {riopay_name}',
'callback': 'topup_riopay',
}
)
if settings.is_support_topup_enabled():
methods.append(
{
@@ -276,6 +288,8 @@ def is_payment_method_available(method_id: str) -> bool:
return settings.is_freekassa_enabled()
if method_id == 'kassa_ai':
return settings.is_kassa_ai_enabled()
if method_id == 'riopay':
return settings.is_riopay_enabled()
if method_id == 'support':
return settings.is_support_topup_enabled()
return False
+15 -19
View File
@@ -18,15 +18,6 @@ def calculate_months_from_days(days: int) -> int:
return max(1, round(days / 30))
def get_remaining_months(end_date: datetime) -> int:
current_time = datetime.now(UTC)
if end_date <= current_time:
return 1
remaining_days = (end_date - current_time).days
return max(1, round(remaining_days / 30))
def calculate_period_multiplier(period_days: int) -> tuple[int, float]:
exact_months = period_days / 30
months_count = max(1, round(exact_months))
@@ -41,20 +32,28 @@ def calculate_period_multiplier(period_days: int) -> tuple[int, float]:
return months_count, exact_months
def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_months: int = 1) -> tuple[int, int]:
months_remaining = get_remaining_months(end_date)
months_to_charge = max(min_charge_months, months_remaining)
def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_days: int = 30) -> tuple[int, int]:
"""Calculate prorated price based on remaining days.
total_price = monthly_price * months_to_charge
Returns:
tuple of (total_price_kopeks, days_charged)
"""
now = datetime.now(UTC)
days_remaining = max(1, (end_date - now).days)
days_to_charge = max(min_charge_days, days_remaining)
total_price = int(monthly_price * days_to_charge / 30)
if monthly_price > 0:
total_price = max(100, total_price) # Минимум 1 рубль
logger.debug(
'Расчет пропорциональной цены: ₽/мес × мес',
'Расчет пропорциональной цены: ₽/мес × дн./30 =',
monthly_price=monthly_price / 100,
months_to_charge=months_to_charge,
days_to_charge=days_to_charge,
total_price=total_price / 100,
)
return total_price, months_to_charge
return total_price, days_to_charge
def apply_percentage_discount(amount: int, percent: int) -> tuple[int, int]:
@@ -156,7 +155,6 @@ async def compute_simple_subscription_price(
period_days=period_days,
)
base_discount = base_price_original * period_discount_percent // 100
base_price_original - base_discount
traffic_discount_percent = resolve_discount_percent(
user,
@@ -165,7 +163,6 @@ async def compute_simple_subscription_price(
period_days=period_days,
)
traffic_discount = traffic_price_original * traffic_discount_percent // 100
traffic_price_original - traffic_discount
devices_discount_percent = resolve_discount_percent(
user,
@@ -174,7 +171,6 @@ async def compute_simple_subscription_price(
period_days=period_days,
)
devices_discount = devices_price_original * devices_discount_percent // 100
devices_price_original - devices_discount
servers_discount_percent = resolve_discount_percent(
user,

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