Compare commits

...

40 Commits

Author SHA1 Message Date
Egor df7e397745 Merge pull request #2918 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.53.0
2026-04-29 12:12:57 +03:00
github-actions[bot] 52868eac5b chore(main): release 3.53.0 2026-04-29 09:12:34 +00:00
Egor 4c600b8557 Merge pull request #2917 from BEDOLAGA-DEV/dev
Dev
2026-04-29 12:11:47 +03:00
Fringg 51dfc3a1a2 feat: protect active paid subscriptions from bulk delete
- Backend: _do_delete_subscription refuses to delete active paid subs
  unless force_delete_active_paid=true is explicitly passed
- Backend: add force_delete_active_paid to BulkActionParams (default false)
- Backend: add is_trial to SubscriptionListItem schema + populate it
2026-04-29 11:31:08 +03:00
Fringg 443a826402 fix: PayPear webhook signature — strip signature field before hashing + IP fallback
The old code hashed the full raw body INCLUDING the 'signature' field
itself — a circular computation that can never match (you can't include
the signature in the data being signed).

Fix:
1. Strip 'signature' key from payload before HMAC-SHA256 computation
2. Try both sorted and unsorted keys (PayPear docs don't specify)
3. Fallback to IP allowlist check (158.160.85.101 per PayPear docs)
4. Pass client_ip from request headers to the verification function
2026-04-29 11:23:20 +03:00
Fringg 06db393488 feat: add bulk_actions, info_pages, news to PERMISSION_REGISTRY
- bulk_actions: read, execute (was using users:edit)
- info_pages: read, create, edit, delete (was using settings:read/edit)
- news: read, create, edit, delete (was missing from registry entirely)

Backend endpoints updated to use dedicated permissions instead of
piggybacking on users:edit / settings:read.
2026-04-29 11:14:24 +03:00
Fringg 0bcb804118 fix: block/unblock endpoints — correct args, response schema, panel sync
4 bugs fixed:
1. block_user() called with User object instead of int user_id, missing admin_id
2. Response used wrong fields (user_id/status instead of old_status/new_status)
3. Return value not checked — reported success even on failure
4. unblock endpoint used DB-only update_user_status instead of UserService.unblock_user
2026-04-29 10:51:23 +03:00
Fringg 735e16afeb fix: cabinet /block endpoint now disables panel user in RemnaWave 2026-04-29 10:47:40 +03:00
Fringg a88e3c80ad fix: traffic addon price mismatch — keyboard showed prorated, handler charged full month
Keyboard calculated: price * days_remaining / 30 (true proration)
Handler calculated: price * max(30, days_remaining) / 30 (always >= 30 days)

With 17 days remaining: keyboard showed 84₽, handler charged 149₽.

Fix: change calculate_prorated_price default min_charge_days from 30 to 1.
Now all callers (traffic, countries, servers, miniapp, auto-purchase)
use true proration matching the displayed price.
2026-04-29 10:42:10 +03:00
Fringg 1110d0c781 fix: media upload leaks staging photo to admin chat
The upload endpoint sent files to the admin notification chat to obtain
a Telegram file_id, but never deleted the staging message. Admins saw
uncontextualized images in their chat before any ticket was created.

Fix: send with disable_notification=True and immediately delete the
staging message after capturing the file_id. Telegram persists file_ids
even after message deletion.
2026-04-29 10:32:55 +03:00
Fringg 62e7ecba01 fix: deadlock on user deletion — webhook handler never checked intentional mark
mark_intentional_panel_deletion was called before api.delete_user,
but _is_intentional_panel_deletion_event was never called in the
webhook handler — it was dead code. The user.deleted webhook processed
unconditionally, causing a deadlock between delete_user_account (Tx1
holding subscription row locks) and the webhook handler (Tx2 trying
to lock the same rows via decrement_subscription_server_counts).

Fix: check _is_intentional_panel_deletion_event at the top of
_handle_user_deleted — if True, log and return immediately without
touching the DB.
2026-04-29 10:28:57 +03:00
Fringg c905fa6000 fix: downgrade Pal24 API validation errors from error to debug 2026-04-29 10:25:08 +03:00
Fringg 768e0b6a73 fix: PollResponse has no created_at — use sent_at for ordering 2026-04-29 10:17:40 +03:00
Fringg 83efc214fe fix: add 6 missing payment providers to payment_utils availability checks
RollyPay (and 5 others) showed buttons but triggered "payment methods
unavailable" because get_available_payment_methods() was missing them.
The keyboard builder (inline.py) had all providers, but the text
generator (payment_utils.py) did not — divergent hand-maintained lists.

Added to all 4 functions: get_available_payment_methods,
is_payment_method_available, get_payment_method_status,
get_enabled_payment_methods_count:
- SeverPay, PayPear, RollyPay, Overpay, AuraPay (new)
- RioPay (was in methods list but missing from status/count)
2026-04-29 08:27:37 +03:00
Fringg 29e177d396 fix: cabinet autopay endpoint — same NULL-safe is_trial guard 2026-04-29 08:21:41 +03:00
Fringg 2fbdbf5ab0 fix: autopay renewing trial subscriptions at classic-mode pricing
Three bugs caused trial subscriptions to be auto-renewed without a
tariff at arbitrary prices:

1. try_auto_extend_expired_after_topup: is_trial guard used truthiness
   check — NULL (legacy rows) passed as falsy. Changed to
   `is_trial is not False` (NULL-safe).

2. Multi-tariff branch: `not s.is_trial` treated NULL as not-trial.
   Changed to `s.is_trial is False`.

3. Telegram bot autopay toggle: no is_trial guard — users could enable
   autopay on trial subscriptions. Added trial check before enabling.
2026-04-29 08:16:39 +03:00
Fringg 422844d78d fix: retry queue action uses _should_create instead of stale subscription UUID 2026-04-29 08:08:24 +03:00
Fringg f37eb9a1bd fix: cabinet purchase fails after panel user deletion — stale UUID
Two bugs caused "RemnaWave UUID не найден" when a user repurchased
after their panel user was deleted (expired user cleanup):

1. Webhook handler only cleared subscription.remnawave_uuid in
   multi-tariff mode. In single-tariff mode the stale UUID remained,
   causing the cabinet to try update_remnawave_user on a deleted
   panel user instead of creating a new one.

2. Cabinet purchase-tariff used subscription.remnawave_uuid for the
   create/update decision. In single-tariff mode this was stale.
   Now mirrors the bot handler logic: checks user.remnawave_uuid
   in single-tariff mode (correctly cleared by webhook).
2026-04-29 08:04:23 +03:00
Fringg 1c38b31e60 fix: send admin notification on promo code activation from cabinet 2026-04-29 07:50:09 +03:00
Fringg 43dd0fd92c fix: referral links now clickable — remove <code> wrapping
The invite message wrapped the entire text including the referral URL
in <blockquote><code>...</code></blockquote>. The <code> tag made the
URL non-clickable — Telegram renders it as monospace copyable text.
Recipients couldn't tap the link to open it.

- Invite message: removed <code> from blockquote, Telegram now auto-links the URL
- Stats panel: removed <code> from bot/cabinet referral links, URLs are now clickable
2026-04-29 07:45:28 +03:00
Fringg a506c6be00 fix: add 5 missing payment providers to pending-payments model_map 2026-04-29 07:40:50 +03:00
Fringg ff7b190527 fix: add RollyPay, PayPear, Overpay, AuraPay to REAL_PAYMENT_METHODS 2026-04-29 07:36:48 +03:00
Fringg 527c5b4498 fix: panel sync subscription duration — ceil for days_remaining 2026-04-29 07:32:08 +03:00
Fringg bada41ecd6 fix: remaining pricing-critical .days floor calculations → math.ceil
Same bug as device pricing: timedelta.days floors partial days.
Fixed 14 more pricing-critical locations across 7 files:

- traffic addon pricing (bot handler + cabinet + miniapp)
- country addon pricing (bot handler + miniapp)
- generic addon pricing helper (common.py)
- auto-purchase device recomputation
- subscription CRUD pricing helper

Display-only .days usages intentionally left as floor (correct for
showing "X days left" to users).
2026-04-29 07:27:55 +03:00
Fringg cf60ae2967 fix: device/traffic addon pricing — use ceil instead of floor for days_left
timedelta.days is integer floor: 29 days 23 hours = 29, not 30.
When a user bought extra devices on the same day as their subscription,
they were charged for ~1 day instead of the full remaining period.

Fix: math.ceil(total_seconds / 86400) rounds partial days UP.
Applied to all 11 locations across 4 files:
- app/handlers/subscription/devices.py (5 spots)
- app/cabinet/routes/subscription_modules/devices.py (3 spots)
- app/keyboards/inline.py (3 spots — display pricing)
- app/utils/pricing_utils.py (1 spot — traffic prorated pricing)
2026-04-29 07:21:07 +03:00
Fringg 47c7d45793 fix: traffic addon discount also bypassed tariff-promo-group check 2026-04-29 07:14:14 +03:00
Fringg 4ab5928b61 fix: promo group discount applied to restricted tariffs in autopay
The pricing engine applied promo group discounts unconditionally,
without checking if the tariff is available for the user's promo group.

In autopay: user with VIP group (60% discount, restricted to Premium
tariff) would get 60% off when auto-renewing a Basic tariff that their
group should not cover.

Fix: in _calculate_tariff_core, check tariff.is_available_for_promo_group
before applying group discounts. If tariff is not available for the
user's promo group, the discount is zeroed — subscription renews at
full price. Protects ALL pricing paths (autopay, recurrent, manual).
2026-04-29 07:09:55 +03:00
Fringg fb857d792b feat: per-category enable/disable for admin notifications
Add ADMIN_NOTIFICATIONS_{CATEGORY}_ENABLED settings (default True) for
all 10 notification categories: purchases, renewals, trials, balance,
addons, infrastructure, errors, promo, partners, tickets.

Setting ADMIN_NOTIFICATIONS_PROMO_ENABLED=false now completely suppresses
promo notifications (promocode activations, campaign visits, promo group
changes) instead of silently falling back to the general topic.

Also fix referral_contest_service direct bot.send_message bypass —
now respects ADMIN_NOTIFICATIONS_PROMO_ENABLED setting.
2026-04-29 06:58:57 +03:00
Fringg 59080f7392 fix: handle A018 error code in admin_users sync endpoints (2 more locations) 2026-04-29 06:52:04 +03:00
Fringg c619dbcae2 fix: handle A018 error code as user-not-found fallback to create_user 2026-04-29 06:48:30 +03:00
Fringg 91de6d03fc fix: update cabinet_last_login on every request (throttled, 5 min) 2026-04-29 06:46:02 +03:00
Fringg 1fc04d842f fix: subscription-request-history — correct API client usage, add ownership check 2026-04-29 06:18:32 +03:00
Fringg e22beb7229 feat: subscription request history API + RemnaWave panel method
- Add get_subscription_request_history to RemnaWave API client
  (GET /api/users/{uuid}/subscription-request-history with pagination)
- Add GET /admin/users/{user_id}/subscription-request-history endpoint
  with subscription_id param for multi-tariff support
2026-04-29 06:12:38 +03:00
Fringg 74999fe99d fix: create locales directory with correct permissions in Dockerfile 2026-04-29 05:55:21 +03:00
Fringg 134e7fb0e1 fix: false subscription expiry notifications — 4 bugs fixed
1. _check_expired_subscription_followups: added Subscription.status=EXPIRED
   filter (was matching ALL statuses including ACTIVE), User.status=ACTIVE
   filter, and 30-day lookback window to stop scanning ancient subscriptions

2. _get_expiring_paid_subscriptions: added User.status=ACTIVE filter to
   prevent sending "expiring" notifications to blocked/deleted users

3. Multi-tariff: before sending expired/followup notifications, check if
   user has another ACTIVE subscription with end_date > now — skip if they
   still have service through another tariff

4. Multi-tariff: same check for _check_expired_subscriptions — don't send
   "subscription expired" if user has another active sub
2026-04-29 05:47:56 +03:00
Fringg c743fc81a5 fix: replace all late callback.answer() with edit_text for error feedback
- Fix 7 intermediate error paths (balance deduction failures) that used
  callback.answer() after the early answer was already consumed — user
  got no error feedback at all
- Fix 2 unfixed handlers: confirm_tariff_purchase, confirm_daily_tariff_purchase
  — same early-answer pattern applied
- All 7 purchase/extend/switch handlers now consistently use early
  callback.answer() + edit_text for errors
2026-04-29 05:37:30 +03:00
Fringg 579e4f2a69 fix: callback.answer() before heavy operations to prevent query timeout
Telegram invalidates callback queries after 30 seconds. When the bot
performed panel sync, DB transactions, and admin notifications before
answering, callback.answer() threw TelegramBadRequest: query is too old.

Moved callback.answer() to immediately after guard checks (balance,
tariff availability) in 5 handlers:
- confirm_tariff_extend
- confirm_custom_tariff_purchase
- confirm_tariff_switch
- confirm_daily_tariff_switch
- confirm_instant_switch

Error feedback now uses callback.message.edit_text() instead of the
expired callback.answer().
2026-04-27 16:56:39 +03:00
Fringg b9b695799c refactor: remove unused EXTERNAL_ADMIN_TOKEN functionality
- Delete app/services/external_admin_service.py entirely
- Remove EXTERNAL_ADMIN_TOKEN and EXTERNAL_ADMIN_TOKEN_BOT_ID from config
- Remove build_external_admin_token, get_external_admin_token, get_external_admin_bot_id methods
- Remove unused hashlib/hmac imports from config.py
- Remove from system_settings_service: READ_ONLY_KEYS, PLAIN_TEXT_KEYS,
  category title, category description, prefix mapping, documentation metadata
- Remove from bot_configuration.py category group
- Remove from main.py startup sequence (ensure_external_admin_token call)
- Remove from .env.example
- Remove from docs/project_structure_reference.md
2026-04-26 19:54:33 +03:00
Fringg 5cf19c76e6 fix: backup import crash + upload handler hardening
- Fix PaypearPayment → PayPearPayment (capital P) — import crash
- Fix AurapayPayment → AuraPayPayment (capital P) — import crash
- Update upload instruction message to mention .tar.gz format
- Add null guard on document.file_name before extension check
2026-04-26 19:39:09 +03:00
Fringg eafb243882 fix: backup completeness — add 15 missing tables, accept .tar.gz uploads
Tables added to backup AND clear lists:
- Payment providers: riopay, severpay, paypear, rollypay, overpay, aurapay, saved_payment_methods
- Content: email_templates, info_pages, news_articles, news_categories, news_tags
- Landing: landing_pages, guest_purchases
- Analytics: yandex_client_id_map

Also:
- Telegram backup upload handler now accepts .tar.gz format (was .json/.json.gz only)
- All 92 ORM models + 3 association tables now covered
2026-04-26 19:29:55 +03:00
52 changed files with 738 additions and 355 deletions
-6
View File
@@ -1029,10 +1029,4 @@ WEB_API_TOKEN_HASH_ALGORITHM=sha256
# Логирование запросов
WEB_API_REQUEST_LOGGING=true
# Внешний админ-токен (для интеграции с другими ботами/системами)
# Токен для доступа через API другого бота
# EXTERNAL_ADMIN_TOKEN=
# ID бота, от которого принимается токен
# EXTERNAL_ADMIN_TOKEN_BOT_ID=
MINIAPP_STATIC_PATH=miniapp
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.52.1"
".": "3.53.0"
}
+51
View File
@@ -1,5 +1,56 @@
# Changelog
## [3.53.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.52.1...v3.53.0) (2026-04-29)
### New Features
* add bulk_actions, info_pages, news to PERMISSION_REGISTRY ([06db393](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/06db3934881bc55851e1ff171fca89abc7deebe5))
* per-category enable/disable for admin notifications ([fb857d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fb857d792b7dd6658df7c081ef46b4cc729cd2fa))
* protect active paid subscriptions from bulk delete ([51dfc3a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/51dfc3a1a2a31706b5c307f6394f2ef9f578cc51))
* subscription request history API + RemnaWave panel method ([e22beb7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e22beb722934779a6d10cb9a7c9a1853f68f7787))
### Bug Fixes
* add 5 missing payment providers to pending-payments model_map ([a506c6b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a506c6be004054998354286fc9dc3990aa867ccc))
* add 6 missing payment providers to payment_utils availability checks ([83efc21](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/83efc214fef7cdec438cf39e4301e6d22bceec7e))
* add RollyPay, PayPear, Overpay, AuraPay to REAL_PAYMENT_METHODS ([ff7b190](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff7b1905271e92625c10fa22d809f27c22a496b6))
* autopay renewing trial subscriptions at classic-mode pricing ([2fbdbf5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2fbdbf5ab0ec0c13100e330f60a2249ed866c12e))
* backup completeness — add 15 missing tables, accept .tar.gz uploads ([eafb243](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eafb243882a2f325771e397cdcc3b258f8ec8f7a))
* backup import crash + upload handler hardening ([5cf19c7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5cf19c76e6fa9456cee0157ac4a4c0742d0f7718))
* block/unblock endpoints — correct args, response schema, panel sync ([0bcb804](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0bcb804118fa9cb02c580eea849b6cd218d931f8))
* cabinet /block endpoint now disables panel user in RemnaWave ([735e16a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/735e16afeba70ef22e690d668e6693cdd2bac140))
* cabinet autopay endpoint — same NULL-safe is_trial guard ([29e177d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29e177d396796827e3970015afb5a84512612035))
* cabinet purchase fails after panel user deletion — stale UUID ([f37eb9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f37eb9a1bd6a149f38458b1a4a1071efd8c03660))
* callback.answer() before heavy operations to prevent query timeout ([579e4f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/579e4f2a695315091db7f014d6a2241852b078ae))
* create locales directory with correct permissions in Dockerfile ([74999fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/74999fe99dd5ef7b10814cb24ff3022aca1d1c0d))
* deadlock on user deletion — webhook handler never checked intentional mark ([62e7ecb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/62e7ecba01601ab3d5aa6c913db064d5fa768d9a))
* device/traffic addon pricing — use ceil instead of floor for days_left ([cf60ae2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf60ae2967b8a5b0c42c51354e3a2513c38e7120))
* downgrade Pal24 API validation errors from error to debug ([c905fa6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c905fa60000c238475cdc3b253ae6e67aa670ff4))
* false subscription expiry notifications — 4 bugs fixed ([134e7fb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/134e7fb0e1999f6404e2d06f38aebdb6af550ec1))
* handle A018 error code as user-not-found fallback to create_user ([c619dbc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c619dbcae2a1470d132cdd35cb9824ac801f117c))
* handle A018 error code in admin_users sync endpoints (2 more locations) ([59080f7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59080f7392412cae848fdd3887cb25304a3a33f9))
* media upload leaks staging photo to admin chat ([1110d0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1110d0c7810d52d4f8f789dc5383cf4a0f5fce96))
* panel sync subscription duration — ceil for days_remaining ([527c5b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/527c5b4498972e2806480af1d0625939eae50bee))
* PayPear webhook signature — strip signature field before hashing + IP fallback ([443a826](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/443a826402e63b021f1efc856257bbf54c79fdb4))
* PollResponse has no created_at — use sent_at for ordering ([768e0b6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/768e0b6a7363e8b496f1e1b83fe567993dc664da))
* promo group discount applied to restricted tariffs in autopay ([4ab5928](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4ab5928b61c35b4af115ced949e373c3373c7145))
* referral links now clickable — remove &lt;code&gt; wrapping ([43dd0fd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43dd0fd92c433498d780ca4e8798d606f5f70dbf))
* remaining pricing-critical .days floor calculations → math.ceil ([bada41e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bada41ecd67e81020627dc50e90d357c088471ab))
* replace all late callback.answer() with edit_text for error feedback ([c743fc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c743fc81a5cb56153dbfe49e4f293277d773e948))
* retry queue action uses _should_create instead of stale subscription UUID ([422844d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/422844d78df2865926c18d888edfb95e72c077c7))
* send admin notification on promo code activation from cabinet ([1c38b31](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1c38b31e60f2491c2366f43b792400af71cba70d))
* subscription-request-history — correct API client usage, add ownership check ([1fc04d8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1fc04d842fe589df1ec6208d6709b5bd55c85da7))
* traffic addon discount also bypassed tariff-promo-group check ([47c7d45](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/47c7d4579336833213203f3e127d4155ed25b555))
* traffic addon price mismatch — keyboard showed prorated, handler charged full month ([a88e3c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a88e3c80ade1a1178270efe5e4100810da6f79b5))
* update cabinet_last_login on every request (throttled, 5 min) ([91de6d0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91de6d03fce4d6082359e7a1ba7e7f5ec02756b6))
### Refactoring
* remove unused EXTERNAL_ADMIN_TOKEN functionality ([b9b6957](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b9b695799cb15f0f3be0116063cbf72482096b5d))
## [3.52.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.52.0...v3.52.1) (2026-04-24)
+3 -3
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.52.1" # x-release-please-version
ARG VERSION="v3.53.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
@@ -33,8 +33,8 @@ WORKDIR /app
COPY --chown=app:app . .
RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails && \
chown -R app:app logs data uploads
RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails locales && \
chown -R app:app logs data uploads locales
USER app
+11
View File
@@ -1,5 +1,7 @@
"""FastAPI dependencies for cabinet module."""
from datetime import UTC, datetime
import structlog
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -176,6 +178,15 @@ async def get_current_cabinet_user(
},
)
# Throttled update of cabinet_last_login (at most every 5 minutes)
now = datetime.now(UTC)
if not user.cabinet_last_login or (now - user.cabinet_last_login).total_seconds() > 300:
try:
user.cabinet_last_login = now
await db.commit()
except Exception:
pass
return user
+11 -1
View File
@@ -502,6 +502,16 @@ async def _do_delete_subscription(
tariff_name = sub.tariff.name if sub.tariff else f'#{sub.id}'
# Protect active paid subscriptions from accidental deletion
if sub.is_active and not sub.is_trial and not params.force_delete_active_paid:
return BulkUserResult(
user_id=user.id,
success=False,
message=f'Skipped: {tariff_name} is active and paid (enable force_delete_active_paid to override)',
username=user.username,
subscriptions=_build_subscription_info(getattr(user, 'subscriptions', None) or []),
)
if dry_run:
return BulkUserResult(
user_id=user.id,
@@ -890,7 +900,7 @@ async def _execute_for_subscription(
async def bulk_execute(
request: BulkExecuteRequest,
stream: bool = Query(default=False, description='Stream progress via SSE'),
admin: User = Depends(require_permission('users:edit')),
admin: User = Depends(require_permission('bulk_actions:execute')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Execute a bulk action on multiple users or subscriptions.
+7 -7
View File
@@ -34,7 +34,7 @@ router = APIRouter(prefix='/admin/info-pages', tags=['Cabinet Admin Info Pages']
@router.get('', response_model=list[InfoPageListItem])
async def list_all_info_pages(
page_type: str | None = Query(None, pattern=r'^(page|faq)$'),
admin: User = Depends(require_permission('settings:read')),
admin: User = Depends(require_permission('info_pages:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> list[InfoPageListItem]:
"""Get all info pages (admin view, includes inactive)."""
@@ -54,7 +54,7 @@ async def list_all_info_pages(
@router.get('/{page_id}', response_model=InfoPageResponse)
async def get_info_page_detail(
page_id: int,
admin: User = Depends(require_permission('settings:read')),
admin: User = Depends(require_permission('info_pages:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Get a single info page by ID (admin view)."""
@@ -70,7 +70,7 @@ async def get_info_page_detail(
@router.post('', response_model=InfoPageResponse, status_code=status.HTTP_201_CREATED)
async def create_page(
request: InfoPageCreateRequest,
admin: User = Depends(require_permission('settings:edit')),
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Create a new info page."""
@@ -108,7 +108,7 @@ async def create_page(
async def update_page(
page_id: int,
request: InfoPageUpdateRequest,
admin: User = Depends(require_permission('settings:edit')),
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Update an existing info page."""
@@ -150,7 +150,7 @@ async def update_page(
@router.delete('/{page_id}', status_code=status.HTTP_204_NO_CONTENT)
async def remove_page(
page_id: int,
admin: User = Depends(require_permission('settings:edit')),
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete an info page."""
@@ -174,7 +174,7 @@ async def remove_page(
@router.post('/reorder', status_code=status.HTTP_204_NO_CONTENT)
async def reorder_pages(
request: ReorderRequest,
admin: User = Depends(require_permission('settings:edit')),
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Bulk update sort_order for info pages."""
@@ -191,7 +191,7 @@ async def reorder_pages(
@router.post('/{page_id}/toggle-active', response_model=InfoPageResponse)
async def toggle_active(
page_id: int,
admin: User = Depends(require_permission('settings:edit')),
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Toggle the active status of an info page."""
+88 -9
View File
@@ -1,5 +1,6 @@
"""Admin routes for managing users in cabinet."""
import math
from datetime import UTC, datetime, timedelta
import structlog
@@ -157,6 +158,7 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt
tariff_id=s.tariff_id,
tariff_name=s.tariff.name if s.tariff else None,
status=s.status,
is_trial=bool(s.is_trial),
end_date=s.end_date,
days_remaining=s_days,
traffic_used_gb=s.traffic_used_gb or 0.0,
@@ -391,7 +393,10 @@ async def _sync_subscription_to_panel(
changes['action'] = 'updated'
logger.info('Updated user in Remnawave panel', user_id=user.id)
except Exception as update_error:
if hasattr(update_error, 'status_code') and update_error.status_code == 404:
error_code = (getattr(update_error, 'response_data', None) or {}).get('errorCode', '')
if (
hasattr(update_error, 'status_code') and update_error.status_code == 404
) or error_code == 'A018':
panel_uuid = None # Will create new
else:
raise
@@ -895,6 +900,50 @@ async def get_user_panel_info(
return UserPanelInfoResponse(found=False)
@router.get('/{user_id}/subscription-request-history')
async def get_subscription_request_history(
user_id: int,
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
):
"""Get subscription request history from RemnaWave panel."""
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
panel_uuid = None
if settings.is_multi_tariff_enabled() and subscription_id:
from app.database.crud.subscription import get_subscription_by_id_for_user
sub = await get_subscription_by_id_for_user(db, subscription_id, user_id)
if sub:
panel_uuid = sub.remnawave_uuid
else:
panel_uuid = getattr(user, 'remnawave_uuid', None)
if not panel_uuid:
return {'total': 0, 'records': []}
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
if not service.is_configured:
return {'total': 0, 'records': []}
async with service.get_api_client() as api:
result = await api.get_subscription_request_history(panel_uuid, offset=offset, limit=limit)
return result
except Exception as e:
logger.error('Error getting subscription request history', user_id=user_id, error=e)
return {'total': 0, 'records': []}
@router.get('/{user_id}/node-usage', response_model=UserNodeUsageResponse)
async def get_user_node_usage(
user_id: int,
@@ -1743,9 +1792,25 @@ async def block_user(
admin: User = Depends(require_permission('users:block')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Block a user (shortcut for status update)."""
request = UpdateUserStatusRequest(status=UserStatusEnum.BLOCKED, reason=reason)
return await update_user_status(user_id, request, admin, db)
"""Block a user — sets DB status AND disables panel user in RemnaWave."""
from app.services.user_service import UserService
user_service = UserService()
success = await user_service.block_user(
db,
user_id,
admin.id,
reason=reason or 'Заблокирован администратором',
)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found or block failed')
return UpdateUserStatusResponse(
success=True,
old_status='active',
new_status='blocked',
message='User blocked',
)
@router.post('/{user_id}/unblock', response_model=UpdateUserStatusResponse)
@@ -1754,9 +1819,20 @@ async def unblock_user(
admin: User = Depends(require_permission('users:block')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unblock a user (shortcut for status update)."""
request = UpdateUserStatusRequest(status=UserStatusEnum.ACTIVE)
return await update_user_status(user_id, request, admin, db)
"""Unblock a user — sets DB status AND re-enables panel user in RemnaWave."""
from app.services.user_service import UserService
user_service = UserService()
success = await user_service.unblock_user(db, user_id, admin.id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found or unblock failed')
return UpdateUserStatusResponse(
success=True,
old_status='blocked',
new_status='active',
message='User unblocked',
)
# === Restrictions Management ===
@@ -3159,7 +3235,7 @@ async def sync_user_from_panel(
int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 100
)
panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at)
days_remaining = max(1, (panel_expire_utc - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((panel_expire_utc - datetime.now(UTC)).total_seconds() / 86400))
new_sub = await create_paid_subscription(
db=db,
@@ -3362,7 +3438,10 @@ async def sync_user_to_panel(
await api.update_user(**update_kwargs)
action = 'updated'
except Exception as update_error:
if hasattr(update_error, 'status_code') and update_error.status_code == 404:
error_code = (getattr(update_error, 'response_data', None) or {}).get('errorCode', '')
if (
hasattr(update_error, 'status_code') and update_error.status_code == 404
) or error_code == 'A018':
# User not found in panel, create new
panel_uuid = None
else:
+10
View File
@@ -1206,15 +1206,20 @@ async def get_latest_payment_by_method(
from sqlalchemy.orm import selectinload
from app.database.models import (
AuraPayPayment,
CloudPaymentsPayment,
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
OverpayPayment,
Pal24Payment,
PayPearPayment,
PlategaPayment,
RioPayPayment,
RollyPayPayment,
SeverPayPayment,
WataPayment,
YooKassaPayment,
)
@@ -1231,6 +1236,11 @@ async def get_latest_payment_by_method(
PaymentMethod.FREEKASSA: FreekassaPayment,
PaymentMethod.KASSA_AI: KassaAiPayment,
PaymentMethod.RIOPAY: RioPayPayment,
PaymentMethod.SEVERPAY: SeverPayPayment,
PaymentMethod.ROLLYPAY: RollyPayPayment,
PaymentMethod.PAYPEAR: PayPearPayment,
PaymentMethod.OVERPAY: OverpayPayment,
PaymentMethod.AURAPAY: AuraPayPayment,
}
model = model_map.get(payment_method)
+10
View File
@@ -99,25 +99,35 @@ async def upload_media(
bot = create_bot()
try:
# Send with disable_notification to avoid pinging admins — this is just staging
if media_type_normalized == 'photo':
message = await bot.send_photo(
chat_id=target_chat_id,
photo=upload,
disable_notification=True,
)
media = message.photo[-1]
elif media_type_normalized == 'video':
message = await bot.send_video(
chat_id=target_chat_id,
video=upload,
disable_notification=True,
)
media = message.video
else:
message = await bot.send_document(
chat_id=target_chat_id,
document=upload,
disable_notification=True,
)
media = message.document
# Delete the staging message immediately — file_id persists after deletion
try:
await bot.delete_message(chat_id=target_chat_id, message_id=message.message_id)
except Exception:
pass # Best-effort cleanup — file_id is already captured
media_url = _build_media_url(request, media.file_id)
logger.info(
+1 -1
View File
@@ -144,7 +144,7 @@ async def get_available_polls(
selectinload(PollResponse.poll).selectinload(Poll.questions),
selectinload(PollResponse.answers),
)
.order_by(PollResponse.created_at.desc())
.order_by(PollResponse.sent_at.desc())
)
responses = result.scalars().all()
+24
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.promocode_service import PromoCodeService
@@ -67,6 +68,29 @@ async def activate_promocode(
balance_before_rubles = result.get('balance_before_kopeks', 0) / 100
balance_after_rubles = result.get('balance_after_kopeks', 0) / 100
# Send admin notification (same as bot handler)
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_promocode_activation_notification(
db,
user,
result.get('promocode', {'code': request.code.strip()}),
result.get('description', ''),
result.get('balance_before_kopeks'),
result.get('balance_after_kopeks'),
)
finally:
await bot.session.close()
except Exception:
pass
return PromocodeActivateResponse(
success=True,
message='Promo code activated successfully',
@@ -49,7 +49,8 @@ async def update_autopay(
)
# Триальные подписки — пробник, автопродление не имеет смысла
if subscription.is_trial:
# NULL-safe: is_trial can be None in legacy rows — treat as trial
if subscription.is_trial is not False:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Autopay is not available for trial subscriptions',
@@ -13,6 +13,7 @@ POST /subscription/devices/save-cart
from __future__ import annotations
import math
from datetime import UTC, datetime
from typing import Any
@@ -380,7 +381,7 @@ async def purchase_devices(
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
days_left = max(1, (end_date - now).days)
days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400))
total_days = 30 # Base period for device price calculation
# Устройства в пределах тарифного лимита — бесплатные
@@ -658,7 +659,7 @@ async def save_devices_cart(
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
days_left = max(1, (end_date - now).days)
days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400))
total_days = 30
# Устройства в пределах тарифного лимита — бесплатные
@@ -772,7 +773,7 @@ async def get_device_price(
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
days_left = max(1, (end_date - now).days)
days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400))
total_days = 30
# Устройства в пределах тарифного лимита — бесплатные
@@ -895,8 +895,14 @@ async def purchase_tariff(
except Exception as trial_err:
logger.warning('Failed to disable trial on RemnaWave', error=trial_err, trial_id=trial_sub.id)
try:
if subscription.remnawave_uuid:
# Existing subscription with Remnawave user — update it
# Mirror the bot handler logic: in single-tariff mode, check user.remnawave_uuid
# (webhook clears it on panel deletion), not subscription.remnawave_uuid
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(user, 'remnawave_uuid', None)
if not _should_create:
await service.update_remnawave_user(
db,
subscription,
@@ -905,7 +911,6 @@ async def purchase_tariff(
sync_squads=True,
)
else:
# New subscription — create new Remnawave user
await service.create_remnawave_user(
db,
subscription,
@@ -919,7 +924,7 @@ async def purchase_tariff(
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create' if not subscription.remnawave_uuid else 'update',
action='create' if _should_create else 'update',
)
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
@@ -9,6 +9,7 @@ POST /subscription/traffic/save-cart
from __future__ import annotations
import math
from datetime import UTC, datetime
from typing import Any
@@ -478,7 +479,7 @@ async def save_traffic_cart(
from app.utils.pricing_utils import calculate_prorated_price as _calc_prorated
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
prorated_price, _ = _calc_prorated(
base_price_kopeks,
subscription.end_date,
+1
View File
@@ -29,6 +29,7 @@ class BulkActionParams(BaseModel):
promo_group_id: int | None = None
device_limit: int | None = Field(None, ge=1, le=50)
delete_from_panel: bool = Field(default=True)
force_delete_active_paid: bool = Field(default=False)
class BulkSubscriptionInfo(BaseModel):
+1
View File
@@ -89,6 +89,7 @@ class SubscriptionListItem(BaseModel):
tariff_id: int | None = None
tariff_name: str | None = None
status: str
is_trial: bool = False
end_date: datetime | None = None
days_remaining: int = 0
traffic_used_gb: float = 0
+12 -36
View File
@@ -1,5 +1,3 @@
import hashlib
import hmac
import html
import os
import re
@@ -67,6 +65,18 @@ class Settings(BaseSettings):
ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID: int | None = None # Промокоды, кампании, промогруппы
ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID: int | None = None # Партнёрки, выводы, админ-действия
# Per-category enable/disable (default True for backwards compatibility)
ADMIN_NOTIFICATIONS_PURCHASES_ENABLED: bool = True
ADMIN_NOTIFICATIONS_RENEWALS_ENABLED: bool = True
ADMIN_NOTIFICATIONS_TRIALS_ENABLED: bool = True
ADMIN_NOTIFICATIONS_BALANCE_ENABLED: bool = True
ADMIN_NOTIFICATIONS_ADDONS_ENABLED: bool = True
ADMIN_NOTIFICATIONS_INFRASTRUCTURE_ENABLED: bool = True
ADMIN_NOTIFICATIONS_ERRORS_ENABLED: bool = True
ADMIN_NOTIFICATIONS_PROMO_ENABLED: bool = True
ADMIN_NOTIFICATIONS_PARTNERS_ENABLED: bool = True
ADMIN_NOTIFICATIONS_TICKETS_ENABLED: bool = True
# Настройки очереди чеков NaloGO
NALOGO_QUEUE_CHECK_INTERVAL: int = 600 # Интервал проверки очереди (секунды, 10 мин)
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
@@ -844,9 +854,6 @@ class Settings(BaseSettings):
BACKUP_SEND_TOPIC_ID: int | None = None
BACKUP_ARCHIVE_PASSWORD: str | None = None
EXTERNAL_ADMIN_TOKEN: str | None = None
EXTERNAL_ADMIN_TOKEN_BOT_ID: int | None = None
# Cabinet (Personal Account) settings
CABINET_ENABLED: bool = False
CABINET_JWT_SECRET: str | None = None
@@ -1653,37 +1660,6 @@ class Settings(BaseSettings):
def get_app_config_cache_ttl(self) -> int:
return self.APP_CONFIG_CACHE_TTL
def build_external_admin_token(self, bot_username: str) -> str:
"""Генерирует детерминированный и криптографически стойкий токен внешней админки."""
normalized = (bot_username or '').strip().lstrip('@').lower()
if not normalized:
raise ValueError('Bot username is required to build external admin token')
secret = (self.BOT_TOKEN or '').strip()
if not secret:
raise ValueError('Bot token is required to build external admin token')
digest = hmac.new(
key=secret.encode('utf-8'),
msg=f'remnawave.external_admin::{normalized}'.encode(),
digestmod=hashlib.sha256,
).hexdigest()
return digest[:48]
def get_external_admin_token(self) -> str | None:
token = (self.EXTERNAL_ADMIN_TOKEN or '').strip()
return token or None
def get_external_admin_bot_id(self) -> int | None:
try:
return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None
except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений
logger.warning(
'Некорректный идентификатор бота для внешней админки',
EXTERNAL_ADMIN_TOKEN_BOT_ID=self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
)
return None
def is_traffic_selectable(self) -> bool:
return self.TRAFFIC_SELECTION_MODE.lower() == 'selectable'
+2 -1
View File
@@ -1,3 +1,4 @@
import math
import secrets
from collections.abc import Iterable
from datetime import UTC, datetime, timedelta
@@ -1296,7 +1297,7 @@ async def add_subscription_servers(
if paid_prices is None:
now = datetime.now(UTC)
days_remaining = max(1, (subscription.end_date - now).days)
days_remaining = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
paid_prices = []
from app.database.models import ServerSquad
+4
View File
@@ -27,6 +27,10 @@ REAL_PAYMENT_METHODS = [
PaymentMethod.KASSA_AI.value,
PaymentMethod.RIOPAY.value,
PaymentMethod.SEVERPAY.value,
PaymentMethod.ROLLYPAY.value,
PaymentMethod.PAYPEAR.value,
PaymentMethod.OVERPAY.value,
PaymentMethod.AURAPAY.value,
]
+21
View File
@@ -541,6 +541,27 @@ class RemnaWaveAPI:
return []
raise
async def get_subscription_request_history(
self,
uuid: str,
offset: int = 0,
limit: int = 20,
) -> dict:
"""Get subscription request history for a panel user.
Returns dict with 'total' and 'records' list.
Each record has: id, userUuid, requestAt, requestIp, userAgent.
"""
try:
response = await self._make_request(
'GET',
f'/api/users/{uuid}/subscription-request-history',
params={'offset': offset, 'limit': limit},
)
return response.get('response', {'total': 0, 'records': []})
except RemnaWaveAPIError:
return {'total': 0, 'records': []}
async def update_user(
self,
uuid: str,
+5 -4
View File
@@ -313,7 +313,7 @@ async def restore_backup_start(callback: types.CallbackQuery, db_user: User, db:
else:
text = """📥 <b>Восстановление из бекапа</b>
📎 Отправьте файл бекапа (.json или .json.gz)
📎 Отправьте файл бекапа (.json, .json.gz или .tar.gz)
<b>ВАЖНО:</b>
Файл должен быть создан этой системой бекапов
@@ -383,7 +383,7 @@ async def restore_backup_execute(callback: types.CallbackQuery, db_user: User, d
async def handle_backup_file_upload(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
if not message.document:
await message.answer(
'❌ Пожалуйста, отправьте файл бекапа (.json или .json.gz)',
'❌ Пожалуйста, отправьте файл бекапа (.json, .json.gz или .tar.gz)',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text='◀️ Отмена', callback_data='backup_panel')]]
),
@@ -391,10 +391,11 @@ async def handle_backup_file_upload(message: types.Message, db_user: User, db: A
return
document = message.document
allowed_extensions = ('.json', '.json.gz', '.tar.gz', '.tar')
if not (document.file_name.endswith('.json') or document.file_name.endswith('.json.gz')):
if not document.file_name or not any(document.file_name.endswith(ext) for ext in allowed_extensions):
await message.answer(
'❌ Неподдерживаемый формат файла. Загрузите .json или .json.gz файл',
'❌ Неподдерживаемый формат файла. Загрузите .json, .json.gz или .tar.gz файл',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text='◀️ Отмена', callback_data='backup_panel')]]
),
-1
View File
@@ -158,7 +158,6 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
'LOG',
'MODERATION',
'DEBUG',
'EXTERNAL_ADMIN',
),
},
}
+3 -4
View File
@@ -120,8 +120,7 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
# Show bot link
referral_text += (
texts.t('REFERRAL_BOT_LINK_TITLE', '🤖 <b>Ссылка на бота:</b>')
+ f'\n<code>{html_escape(bot_referral_link)}</code>\n'
texts.t('REFERRAL_BOT_LINK_TITLE', '🤖 <b>Ссылка на бота:</b>') + f'\n{html_escape(bot_referral_link)}\n'
)
# Show cabinet link if configured
@@ -129,7 +128,7 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
referral_text += (
'\n'
+ texts.t('REFERRAL_CABINET_LINK_TITLE', '🌐 <b>Ссылка на кабинет:</b>')
+ f'\n<code>{html_escape(cabinet_referral_link)}</code>\n'
+ f'\n{html_escape(cabinet_referral_link)}\n'
)
referral_text += (
@@ -551,7 +550,7 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
'Нажмите на текст ниже, чтобы скопировать:',
)
+ '\n\n'
f'<blockquote><code>{html_escape(invite_text)}</code></blockquote>'
f'<blockquote>{html_escape(invite_text)}</blockquote>'
),
keyboard,
)
+12
View File
@@ -101,6 +101,18 @@ async def toggle_autopay(callback: types.CallbackQuery, db_user: User, db: Async
enable = callback.data.startswith('autopay_enable')
if enable:
# Trial subscriptions cannot use autopay
if subscription.is_trial or subscription.is_trial is None:
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
'AUTOPAY_NOT_AVAILABLE_TRIAL',
'Автоплатеж недоступен для пробных подписок.',
),
show_alert=True,
)
return
# Classic subscriptions cannot use autopay when tariff mode is enabled
if settings.is_tariffs_mode() and not subscription.tariff_id:
texts = get_texts(db_user.language)
+2 -1
View File
@@ -1,6 +1,7 @@
import asyncio
import base64
import html as html_mod
import math
import re
import time
from datetime import UTC, datetime
@@ -545,7 +546,7 @@ def get_traffic_switch_keyboard(
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
price_multiplier = days_left / 30
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
else:
+2 -1
View File
@@ -1,4 +1,5 @@
import html
import math
from datetime import UTC, datetime
from aiogram import types
@@ -266,7 +267,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
logger.info('🔧 Добавлено: Удалено', added=added, removed=removed)
now = datetime.now(UTC)
days_to_pay = max(1, (subscription.end_date - now).days)
days_to_pay = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_to_pay if days_to_pay > 0 else None
+6 -5
View File
@@ -1,4 +1,5 @@
import html as html_mod
import math
from datetime import UTC, datetime
from aiogram import types
@@ -343,7 +344,7 @@ async def confirm_change_devices(
# Считаем стоимость по оставшимся дням подписки
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_left
devices_discount_percent = PricingEngine.get_addon_discount_percent(
@@ -572,7 +573,7 @@ async def execute_change_devices(
chargeable_devices = devices_difference
devices_price_per_month = chargeable_devices * price_per_device
days_left = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_left = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
devices_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'devices',
@@ -601,7 +602,7 @@ async def execute_change_devices(
)
return
charged_days = max(1, (subscription.end_date - datetime.now(UTC)).days)
charged_days = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
await create_transaction(
db=db,
user_id=db_user.id,
@@ -1253,7 +1254,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
if is_daily_tariff:
# Для суточных тарифов считаем по дням (как в кабинете)
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_left
devices_discount_percent = PricingEngine.get_addon_discount_percent(
@@ -1274,7 +1275,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
else:
# Для обычных тарифов - по дням (как в кабинете)
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_left
devices_discount_percent = PricingEngine.get_addon_discount_percent(
+135 -33
View File
@@ -939,6 +939,13 @@ async def handle_custom_confirm(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
# Save promo offer state before deduction (for restore on failure)
@@ -958,11 +965,17 @@ async def handle_custom_confirm(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
except Exception as e:
logger.error('Ошибка списания баланса при покупке кастомного тарифа', error=e, exc_info=True)
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из тарифа
@@ -1049,7 +1062,10 @@ async def handle_custom_confirm(
price_kopeks=total_price,
refund_error=refund_error,
)
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки')
except Exception:
pass
return
try:
@@ -1148,11 +1164,12 @@ async def handle_custom_confirm(
),
parse_mode='HTML',
)
await callback.answer('Подписка оформлена!', show_alert=True)
except Exception as e:
logger.error('Ошибка при покупке тарифа с кастомными параметрами', error=e, exc_info=True)
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки')
except Exception:
pass
@error_handler
@@ -1377,6 +1394,13 @@ async def confirm_tariff_purchase(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
# Списываем баланс
@@ -1395,11 +1419,17 @@ async def confirm_tariff_purchase(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
except Exception as e:
logger.error('Ошибка списания баланса при покупке тарифа', error=e, exc_info=True)
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из тарифа
@@ -1457,10 +1487,12 @@ async def confirm_tariff_purchase(
db_user.promo_offer_discount_source = saved_promo_source
db_user.promo_offer_discount_expires_at = saved_promo_expires
await db.commit()
await callback.answer(
f'Максимум подписок: {settings.get_max_active_subscriptions()}',
show_alert=True,
)
try:
await callback.message.edit_text(
f'❌ Максимум подписок: {settings.get_max_active_subscriptions()}'
)
except Exception:
pass
return
# Create NEW subscription for this tariff (multi-tariff: new Remnawave user)
@@ -1537,7 +1569,10 @@ async def confirm_tariff_purchase(
reason='Возврат: тариф уже активен',
error=refund_error,
)
await callback.answer('У вас уже есть активная подписка на этот тариф', show_alert=True)
try:
await callback.message.edit_text('❌ У вас уже есть активная подписка на этот тариф')
except Exception:
pass
return
except Exception as e:
logger.error('Ошибка создания/продления подписки при покупке тарифа', error=e, exc_info=True)
@@ -1581,7 +1616,10 @@ async def confirm_tariff_purchase(
reason='Возврат: ошибка покупки тарифа',
error=refund_error,
)
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки')
except Exception:
pass
return
# Обновляем пользователя в Remnawave
@@ -1686,7 +1724,6 @@ async def confirm_tariff_purchase(
),
parse_mode='HTML',
)
await callback.answer('Подписка оформлена!', show_alert=True)
# ==================== Покупка суточного тарифа ====================
@@ -1741,6 +1778,13 @@ async def confirm_daily_tariff_purchase(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
try:
@@ -1754,11 +1798,17 @@ async def confirm_daily_tariff_purchase(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
except Exception as e:
logger.error('Ошибка списания баланса при покупке суточного тарифа', error=e, exc_info=True)
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из тарифа
@@ -1864,7 +1914,10 @@ async def confirm_daily_tariff_purchase(
price_kopeks=final_daily_price,
refund_error=refund_error,
)
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки')
except Exception:
pass
return
# Обновляем пользователя в Remnawave
@@ -1964,7 +2017,6 @@ async def confirm_daily_tariff_purchase(
),
parse_mode='HTML',
)
await callback.answer('Подписка оформлена!', show_alert=True)
# ==================== Продление по тарифу ====================
@@ -2364,6 +2416,13 @@ async def confirm_tariff_extend(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
try:
@@ -2377,7 +2436,10 @@ async def confirm_tariff_extend(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Запоминаем, был ли триал ДО продления
@@ -2487,11 +2549,12 @@ async def confirm_tariff_extend(
),
parse_mode='HTML',
)
await callback.answer('Подписка продлена!', show_alert=True)
except Exception as e:
logger.error('Ошибка при продлении тарифа', error=e, exc_info=True)
await callback.answer('Произошла ошибка при продлении подписки', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при продлении подписки')
except Exception:
pass
# ==================== Переключение тарифов ====================
@@ -3031,6 +3094,13 @@ async def confirm_tariff_switch(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
try:
@@ -3044,7 +3114,10 @@ async def confirm_tariff_switch(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из тарифа
@@ -3198,11 +3271,13 @@ async def confirm_tariff_switch(
),
parse_mode='HTML',
)
await callback.answer('Тариф изменён!', show_alert=True)
except Exception as e:
logger.error('Ошибка при переключении тарифа', error=e, exc_info=True)
await callback.answer('Произошла ошибка при переключении тарифа', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при переключении тарифа')
except Exception:
pass
# ==================== Смена на суточный тариф ====================
@@ -3276,6 +3351,13 @@ async def confirm_daily_tariff_switch(
await callback.answer('Понижение тарифа недоступно', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
try:
@@ -3289,7 +3371,10 @@ async def confirm_daily_tariff_switch(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из тарифа
@@ -3445,7 +3530,6 @@ async def confirm_daily_tariff_switch(
),
parse_mode='HTML',
)
await callback.answer('Тариф изменён!', show_alert=True)
except Exception as e:
logger.error('Ошибка при смене на суточный тариф', error=e, exc_info=True)
@@ -3478,7 +3562,10 @@ async def confirm_daily_tariff_switch(
price_kopeks=final_daily_price,
refund_error=refund_error,
)
await callback.answer('Произошла ошибка при смене тарифа', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при смене тарифа')
except Exception:
pass
# ==================== Мгновенное переключение тарифов (без выбора периода) ====================
@@ -3976,6 +4063,13 @@ async def confirm_instant_switch(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
try:
@@ -3991,7 +4085,10 @@ async def confirm_instant_switch(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из нового тарифа
@@ -4058,7 +4155,10 @@ async def confirm_instant_switch(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('❌ Недостаточно средств', show_alert=True)
try:
await callback.message.edit_text('❌ Недостаточно средств')
except Exception:
pass
return
await create_transaction(
db,
@@ -4232,11 +4332,13 @@ async def confirm_instant_switch(
),
parse_mode='HTML',
)
await callback.answer('Тариф изменён!', show_alert=True)
except Exception as e:
logger.error('Ошибка при мгновенном переключении тарифа', error=e, exc_info=True)
await callback.answer('Произошла ошибка при переключении тарифа', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при переключении тарифа')
except Exception:
pass
async def return_to_saved_tariff_cart(
+4 -3
View File
@@ -1,3 +1,4 @@
import math
from datetime import UTC, datetime
from aiogram import types
@@ -807,7 +808,7 @@ async def confirm_switch_traffic(
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
now = datetime.now(UTC)
days_remaining = max(1, (subscription.end_date - now).days)
days_remaining = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_remaining if days_remaining > 0 else None
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
@@ -911,7 +912,7 @@ async def execute_switch_traffic(
base_traffic = current_traffic - purchased_traffic
old_price_per_month = settings.get_traffic_price(base_traffic)
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'traffic',
@@ -936,7 +937,7 @@ async def execute_switch_traffic(
await callback.answer('⚠️ Ошибка списания средств', show_alert=True)
return
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
await create_transaction(
db=db,
user_id=db_user.id,
+4 -3
View File
@@ -1,3 +1,4 @@
import math
from datetime import UTC, datetime
import structlog
@@ -2169,7 +2170,7 @@ def get_add_traffic_keyboard(
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
price_multiplier = days_left / 30
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
else:
@@ -2311,7 +2312,7 @@ def get_change_devices_keyboard(
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
price_multiplier = days_left / 30
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
else:
@@ -2473,7 +2474,7 @@ def get_manage_countries_keyboard(
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
price_multiplier = days_left / 30
logger.info(
'🔍 Расчет для управления странами: осталось дней до',
@@ -67,6 +67,12 @@ class AdminNotificationService:
NotificationCategory.TICKETS: self.ticket_topic_id,
}
# Per-category enabled flags (default True — backwards compatible)
self.category_enabled: dict[NotificationCategory, bool] = {}
for cat in NotificationCategory:
key = f'ADMIN_NOTIFICATIONS_{cat.value.upper()}_ENABLED'
self.category_enabled[cat] = getattr(settings, key, True)
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: int | None) -> str:
if not referred_by_id:
return 'Нет'
@@ -1266,6 +1272,11 @@ class AdminNotificationService:
logger.warning('ADMIN_NOTIFICATIONS_CHAT_ID не настроен')
return False
# Per-category suppression
if category and not self.category_enabled.get(category, True):
logger.debug('Уведомление подавлено (категория отключена)', category=category.value)
return False
try:
message_kwargs = {
'chat_id': self.chat_id,
+51
View File
@@ -31,6 +31,7 @@ from app.database.models import (
AdminRole,
AdvertisingCampaign,
AdvertisingCampaignRegistration,
AuraPayPayment,
BroadcastHistory,
ButtonClickLog,
CabinetRefreshToken,
@@ -40,18 +41,27 @@ from app.database.models import (
ContestTemplate,
CryptoBotPayment,
DiscountOffer,
EmailTemplate,
FaqPage,
FaqSetting,
FreekassaPayment,
GuestPurchase,
HeleketPayment,
InfoPage,
KassaAiPayment,
LandingPage,
MainMenuButton,
MenuLayoutHistory,
MonitoringLog,
MulenPayPayment,
NewsArticle,
NewsCategory,
NewsTag,
OverpayPayment,
Pal24Payment,
PartnerApplication,
PaymentMethodConfig,
PayPearPayment,
PinnedMessage,
PlategaPayment,
Poll,
@@ -71,9 +81,13 @@ from app.database.models import (
ReferralContestVirtualParticipant,
ReferralEarning,
RequiredChannel,
RioPayPayment,
RollyPayPayment,
SavedPaymentMethod,
SentNotification,
ServerSquad,
ServiceRule,
SeverPayPayment,
Squad,
Subscription,
SubscriptionConversion,
@@ -102,6 +116,7 @@ from app.database.models import (
WheelPrize,
WheelSpin,
WithdrawalRequest,
YandexClientIdMap,
YooKassaPayment,
payment_method_promo_groups,
server_squad_promo_groups,
@@ -183,6 +198,13 @@ class BackupService:
CloudPaymentsPayment,
FreekassaPayment,
KassaAiPayment,
RioPayPayment,
SeverPayPayment,
PayPearPayment,
RollyPayPayment,
OverpayPayment,
AuraPayPayment,
SavedPaymentMethod,
# --- Settings/content ---
PaymentMethodConfig,
PrivacyPolicy,
@@ -192,6 +214,17 @@ class BackupService:
PinnedMessage,
MainMenuButton,
MenuLayoutHistory,
EmailTemplate,
InfoPage,
# --- News (FK: none / self-contained) ---
NewsCategory,
NewsTag,
NewsArticle,
# --- Landing / Guest purchases (FK: users, tariffs, landings) ---
LandingPage,
GuestPurchase,
# --- Yandex analytics (FK: users) ---
YandexClientIdMap,
# --- User data (FK: users, promo_groups, subscriptions) ---
UserPromoGroup,
TrafficPurchase,
@@ -1476,6 +1509,13 @@ class BackupService:
'cloudpayments_payments',
'freekassa_payments',
'kassa_ai_payments',
'riopay_payments',
'severpay_payments',
'paypear_payments',
'rollypay_payments',
'overpay_payments',
'aurapay_payments',
'saved_payment_methods',
# --- Content/config ---
'pinned_messages',
'main_menu_buttons',
@@ -1485,6 +1525,17 @@ class BackupService:
'privacy_policies',
'public_offers',
'payment_method_configs',
'email_templates',
'info_pages',
# --- News ---
'news_articles',
'news_categories',
'news_tags',
# --- Landing / Guest purchases ---
'guest_purchases',
'landing_pages',
# --- Yandex analytics ---
'yandex_client_id_map',
# --- Support ---
'support_audit_logs',
'ticket_messages',
-146
View File
@@ -1,146 +0,0 @@
"""Утилиты для синхронизации токена внешней админки."""
from __future__ import annotations
import structlog
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import SystemSetting
from app.services.system_settings_service import (
ReadOnlySettingError,
bot_configuration_service,
)
logger = structlog.get_logger(__name__)
async def ensure_external_admin_token(
bot_username: str | None,
bot_id: int | None,
) -> str | None:
"""Генерирует и сохраняет токен внешней админки, если требуется."""
username_raw = (bot_username or '').strip()
if not username_raw:
logger.warning(
'⚠️ Не удалось обеспечить токен внешней админки: username бота отсутствует',
)
return None
normalized_username = username_raw.lstrip('@').lower()
if not normalized_username:
logger.warning(
'⚠️ Не удалось обеспечить токен внешней админки: username пустой после нормализации',
)
return None
try:
token = settings.build_external_admin_token(normalized_username)
except Exception as error: # pragma: no cover - защитный блок
logger.error('❌ Ошибка генерации токена внешней админки', error=error)
return None
try:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(SystemSetting.key, SystemSetting.value).where(
SystemSetting.key.in_(['EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'])
)
)
rows = dict(result.all())
existing_token = rows.get('EXTERNAL_ADMIN_TOKEN')
existing_bot_id_raw = rows.get('EXTERNAL_ADMIN_TOKEN_BOT_ID')
existing_bot_id: int | None = None
if existing_bot_id_raw is not None:
try:
existing_bot_id = int(existing_bot_id_raw)
except (TypeError, ValueError): # pragma: no cover - защита от мусорных значений
logger.warning(
'⚠️ Не удалось разобрать сохраненный идентификатор бота внешней админки',
existing_bot_id_raw=existing_bot_id_raw,
)
if existing_token == token and existing_bot_id == bot_id:
if settings.get_external_admin_token() != token:
settings.EXTERNAL_ADMIN_TOKEN = token
if existing_bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID:
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
return token
if existing_bot_id is not None and bot_id is not None and existing_bot_id != bot_id:
logger.error(
'❌ Обнаружено несовпадение ID бота для токена внешней админки: сохранен , текущий',
existing_bot_id=existing_bot_id,
bot_id=bot_id,
)
try:
await bot_configuration_service.reset_value(
session,
'EXTERNAL_ADMIN_TOKEN',
force=True,
)
await bot_configuration_service.reset_value(
session,
'EXTERNAL_ADMIN_TOKEN_BOT_ID',
force=True,
)
await session.commit()
logger.warning(
'⚠️ Токен внешней админки очищен из-за несовпадения идентификаторов бота',
)
except Exception as cleanup_error: # pragma: no cover - защитный блок
await session.rollback()
logger.error(
'❌ Не удалось очистить токен внешней админки после обнаружения подмены',
cleanup_error=cleanup_error,
)
finally:
settings.EXTERNAL_ADMIN_TOKEN = None
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = None
return None
updates: list[tuple[str, object]] = []
if existing_token != token:
updates.append(('EXTERNAL_ADMIN_TOKEN', token))
if bot_id is not None and existing_bot_id != bot_id:
updates.append(('EXTERNAL_ADMIN_TOKEN_BOT_ID', bot_id))
if not updates:
# Токен совпал, но могли отсутствовать значения в настройках приложения
if settings.get_external_admin_token() != (existing_token or token):
settings.EXTERNAL_ADMIN_TOKEN = existing_token or token
if existing_bot_id is not None and (existing_bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID):
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
elif bot_id is not None and bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID and existing_bot_id is None:
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = bot_id
return existing_token or token
try:
for key, value in updates:
await bot_configuration_service.set_value(
session,
key,
value,
force=True,
)
await session.commit()
logger.info('✅ Токен внешней админки синхронизирован для @', normalized_username=normalized_username)
except ReadOnlySettingError: # pragma: no cover - force=True предотвращает исключение
await session.rollback()
logger.warning(
'⚠️ Не удалось сохранить токен внешней админки из-за ограничения доступа',
)
return None
return token
except SQLAlchemyError as error:
logger.error('❌ Ошибка сохранения токена внешней админки', error=error)
return None
+40 -1
View File
@@ -373,7 +373,22 @@ class MonitoringService:
user = await get_user_by_id(db, subscription.user_id)
if user and self.bot:
await self._send_subscription_expired_notification(user, subscription, tariff_name=_tariff_name)
# Skip notification if user has another ACTIVE subscription (multi-tariff)
skip_notify = False
if settings.is_multi_tariff_enabled():
other_active = await db.execute(
select(Subscription.id)
.where(
Subscription.user_id == user.id,
Subscription.id != subscription.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > datetime.now(UTC),
)
.limit(1)
)
skip_notify = other_active.scalar_one_or_none() is not None
if not skip_notify:
await self._send_subscription_expired_notification(user, subscription, tariff_name=_tariff_name)
logger.info(
"🔴 Подписка пользователя истекла и статус изменен на 'expired'", user_id=subscription.user_id
@@ -965,8 +980,12 @@ class MonitoringService:
try:
now = datetime.now(UTC)
# Lookback window — don't re-check subscriptions expired more than 30 days ago
lookback = now - timedelta(days=30)
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
@@ -974,7 +993,10 @@ class MonitoringService:
.where(
and_(
Subscription.is_trial == False,
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date <= now,
Subscription.end_date >= lookback,
User.status == UserStatus.ACTIVE.value,
)
)
)
@@ -998,6 +1020,21 @@ class MonitoringService:
if subscription.end_date is None:
continue
# Skip if user has another ACTIVE subscription — they still have service
if settings.is_multi_tariff_enabled():
other_active = await db.execute(
select(Subscription.id)
.where(
Subscription.user_id == user.id,
Subscription.id != subscription.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now,
)
.limit(1)
)
if other_active.scalar_one_or_none() is not None:
continue
time_since_end = now - subscription.end_date
if time_since_end.total_seconds() < 0:
continue
@@ -1090,6 +1127,7 @@ class MonitoringService:
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
@@ -1100,6 +1138,7 @@ class MonitoringService:
Subscription.is_trial == False,
Subscription.end_date > current_time,
Subscription.end_date <= threshold_date,
User.status == UserStatus.ACTIVE.value,
)
)
)
+4 -4
View File
@@ -558,8 +558,8 @@ class Pal24PaymentMixin:
payment_id_str = str(payment.payment_id)
try:
payment_response = await service.get_payment_status(payment_id_str)
except Pal24APIError as error:
logger.error('Ошибка Pal24 API при получении статуса платежа', error=error)
except Pal24APIError:
logger.debug('Pal24 payment_id не найден или невалиден', payment_id=payment_id_str)
else:
if payment_response:
remote_payloads['payment_status'] = payment_response
@@ -569,8 +569,8 @@ class Pal24PaymentMixin:
try:
payments_response = await service.get_bill_payments(bill_id_str)
except Pal24APIError as error:
logger.error('Ошибка Pal24 API при получении списка платежей', error=error)
except Pal24APIError:
logger.debug('Pal24 bill payments не найдены', bill_id=bill_id_str)
else:
if payments_response:
remote_payloads['bill_payments'] = payments_response
+47 -17
View File
@@ -208,27 +208,57 @@ class PayPearService:
logger.exception('PayPear API connection error', error=e)
raise
def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool:
"""Верификация подписи webhook PayPear через HMAC-SHA256.
# PayPear documented webhook source IPs
WEBHOOK_ALLOWED_IPS: set[str] = {'158.160.85.101'}
PayPear sends signature in the webhook JSON field 'signature'.
The signature is HMAC-SHA256(secret_key, raw_body).
def verify_webhook_signature(self, raw_body: bytes, received_signature: str, client_ip: str | None = None) -> bool:
"""Верификация webhook PayPear.
PayPear documentation does not specify the exact signature algorithm.
We try HMAC-SHA256(secret_key, body_without_signature_field) the most common pattern.
If signature verification fails, fall back to IP allowlist check (recommended by PayPear docs).
"""
try:
if not received_signature:
logger.warning('PayPear webhook: отсутствует signature')
return False
import json as json_mod
expected = hmac.new(
self.secret_key.encode('utf-8'),
raw_body,
hashlib.sha256,
).hexdigest()
# Try signature verification (body without 'signature' field, sorted keys, compact separators)
if received_signature and self.secret_key:
try:
payload = json_mod.loads(raw_body)
payload_without_sig = {k: v for k, v in payload.items() if k != 'signature'}
body_to_sign = json_mod.dumps(payload_without_sig, separators=(',', ':'), sort_keys=True).encode(
'utf-8'
)
return hmac.compare_digest(expected, received_signature)
except Exception as e:
logger.error('PayPear webhook verify error', error=e)
return False
expected = hmac.new(
self.secret_key.encode('utf-8'),
body_to_sign,
hashlib.sha256,
).hexdigest()
if hmac.compare_digest(expected, received_signature):
return True
# Try without sort_keys (original key order)
body_to_sign_unsorted = json_mod.dumps(payload_without_sig, separators=(',', ':')).encode('utf-8')
expected_unsorted = hmac.new(
self.secret_key.encode('utf-8'),
body_to_sign_unsorted,
hashlib.sha256,
).hexdigest()
if hmac.compare_digest(expected_unsorted, received_signature):
return True
logger.debug('PayPear signature mismatch, falling back to IP check')
except Exception as e:
logger.debug('PayPear signature verify error, falling back to IP check', error=e)
# Fallback: IP allowlist (recommended by PayPear docs)
if client_ip and client_ip in self.WEBHOOK_ALLOWED_IPS:
return True
logger.warning('PayPear webhook: signature mismatch and IP not in allowlist', client_ip=client_ip)
return False
# Singleton instance
+3
View File
@@ -77,6 +77,9 @@ PERMISSION_REGISTRY: dict[str, list[str]] = {
'pinned_messages': ['read', 'create', 'edit', 'delete'],
'landings': ['read', 'create', 'edit', 'delete'],
'updates': ['read', 'manage'],
'bulk_actions': ['read', 'execute'],
'info_pages': ['read', 'create', 'edit', 'delete'],
'news': ['read', 'create', 'edit', 'delete'],
}
+5 -2
View File
@@ -603,6 +603,9 @@ class PricingEngine:
period_pct = 0
devices_pct = 0
promo_group = self.resolve_promo_group(user)
# Only apply promo group discount if the tariff is available for this group
if promo_group is not None and not tariff.is_available_for_promo_group(promo_group.id):
promo_group = None
if promo_group is not None:
period_pct = promo_group.get_discount_percent('period', period_days)
devices_pct = promo_group.get_discount_percent('devices', period_days)
@@ -612,9 +615,9 @@ class PricingEngine:
discounted_base = self.apply_discount(base_price, period_pct)
discounted_devices = self.apply_discount(devices_price, devices_pct)
# Traffic uses addon discount (checks apply_discounts_to_addons flag)
# Traffic uses addon discount — but only if promo_group passed the tariff availability check
discounted_traffic = traffic_price
if traffic_price > 0 and user:
if traffic_price > 0 and user and promo_group is not None:
discounted_traffic, _, _ = self.calculate_traffic_discount(traffic_price, user)
base_group_disc = base_price - discounted_base
+4
View File
@@ -301,6 +301,10 @@ class ReferralContestService:
lines.append('')
lines.append(f'Приз: {html.escape(contest.prize_text)}')
# Respect per-category enable/disable
if not getattr(settings, 'ADMIN_NOTIFICATIONS_PROMO_ENABLED', True):
return
try:
await self.bot.send_message(
chat_id=chat_id,
+3 -1
View File
@@ -2397,7 +2397,9 @@ class RemnaWaveService:
user.remnawave_uuid = panel_uuid
return ('updated', sub, None)
except RemnaWaveAPIError as api_error:
if api_error.status_code == 404:
# A018 = "user not found" in some RemnaWave versions (may return 400 or 404)
error_code = (api_error.response_data or {}).get('errorCode', '')
if api_error.status_code == 404 or error_code == 'A018':
new_user = await api.create_user(**create_kwargs)
return ('created', sub, new_user)
raise
+12 -2
View File
@@ -994,6 +994,16 @@ class RemnaWaveWebhookService:
async def _handle_user_deleted(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
# Suppress webhook if this deletion was initiated by delete_user_account —
# prevents deadlock between the ongoing deletion transaction and this handler
if self._is_intentional_panel_deletion_event(data):
logger.info(
'Webhook user.deleted suppressed — intentional panel deletion in progress',
user_id=user.id,
uuid=data.get('uuid'),
)
return
user_id = user.id
sub_id = subscription.id if subscription else None
@@ -1075,8 +1085,8 @@ class RemnaWaveWebhookService:
subscription.connected_squads = []
subscription.updated_at = datetime.now(UTC)
if settings.is_multi_tariff_enabled():
subscription.remnawave_uuid = None
# Always clear stale UUID — panel user was deleted
subscription.remnawave_uuid = None
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id))
@@ -3,6 +3,7 @@
from __future__ import annotations
import html
import math
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
@@ -1533,7 +1534,7 @@ async def _auto_add_devices(
# Recompute price fresh under lock (pricing config may have changed since cart was saved)
devices_price_per_month = devices_to_add * tariff_device_price
days_left = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_left = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
devices_discount_percent = PricingEngine.get_addon_discount_percent(
user,
'devices',
@@ -2137,7 +2138,7 @@ async def try_auto_extend_expired_after_topup(
from app.database.crud.subscription import get_all_subscriptions_by_user_id
all_subs = await get_all_subscriptions_by_user_id(db, user.id)
expired_subs = [s for s in all_subs if s.status == SubscriptionStatus.EXPIRED.value and not s.is_trial]
expired_subs = [s for s in all_subs if s.status == SubscriptionStatus.EXPIRED.value and s.is_trial is False]
if not expired_subs:
subscription = None
else:
@@ -2153,9 +2154,10 @@ async def try_auto_extend_expired_after_topup(
return False
# Only process expired subscriptions (not trial, not disabled)
# NULL-safe: is_trial can be None in legacy rows — treat as trial
if subscription.status != SubscriptionStatus.EXPIRED.value:
return False
if subscription.is_trial:
if subscription.is_trial is not False:
return False
# Only process subscriptions expired within the last 30 days
+2 -19
View File
@@ -70,8 +70,8 @@ class ReadOnlySettingError(RuntimeError):
class BotConfigurationService:
EXCLUDED_KEYS: set[str] = {'BOT_TOKEN', 'ADMIN_IDS'}
READ_ONLY_KEYS: set[str] = {'EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'}
PLAIN_TEXT_KEYS: set[str] = {'EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'}
READ_ONLY_KEYS: set[str] = set()
PLAIN_TEXT_KEYS: set[str] = set()
CATEGORY_TITLES: dict[str, str] = {
'CORE': '🤖 Основные настройки',
@@ -101,7 +101,6 @@ class BotConfigurationService:
'MULENPAY': '💰 {mulenpay_name}',
'PAL24': '🏦 PAL24 / PayPalych',
'WATA': '💠 Wata',
'EXTERNAL_ADMIN': '🛡️ Внешняя админка',
'SUBSCRIPTIONS_CORE': '📅 Подписки и лимиты',
'SIMPLE_SUBSCRIPTION': '⚡ Простая покупка',
'PERIODS': '📆 Периоды подписок',
@@ -168,7 +167,6 @@ class BotConfigurationService:
'TELEGRAM_WIDGET': 'Внешний вид виджета авторизации Telegram на странице входа в кабинет.',
'TELEGRAM_OIDC': 'OpenID Connect авторизация через Telegram (новая система). Требует настройки в BotFather > Bot Settings > Web Login.',
'WATA': 'Wata: токен доступа, тип платежа и пределы сумм.',
'EXTERNAL_ADMIN': 'Токен внешней админки для проверки запросов.',
'SUBSCRIPTIONS_CORE': 'Лимиты устройств, трафика и базовые цены подписок.',
'SIMPLE_SUBSCRIPTION': 'Параметры упрощённой покупки: период, трафик, устройства и сквады.',
'PERIODS': 'Доступные периоды подписок и продлений.',
@@ -381,7 +379,6 @@ class BotConfigurationService:
'PAYMENT_': 'PAYMENT',
'PAYMENT_VERIFICATION_': 'PAYMENT_VERIFICATION',
'WATA_': 'WATA',
'EXTERNAL_ADMIN_': 'EXTERNAL_ADMIN',
'SIMPLE_SUBSCRIPTION_': 'SIMPLE_SUBSCRIPTION',
'CONNECT_BUTTON_HAPP': 'HAPP',
'HAPP_': 'HAPP',
@@ -738,20 +735,6 @@ class BotConfigurationService:
'Если результат пустой, используется user_{telegram_id}.'
),
},
'EXTERNAL_ADMIN_TOKEN': {
'description': 'Приватный токен, который использует внешняя админка для проверки запросов.',
'format': 'Значение генерируется автоматически из username бота и его токена и доступно только для чтения.',
'example': 'Генерируется автоматически',
'warning': 'Токен обновится при смене username или токена бота.',
'dependencies': 'Username телеграм-бота, токен бота',
},
'EXTERNAL_ADMIN_TOKEN_BOT_ID': {
'description': 'Идентификатор телеграм-бота, с которым связан токен внешней админки.',
'format': 'Проставляется автоматически после первого запуска и не редактируется вручную.',
'example': '123456789',
'warning': 'Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.',
'dependencies': 'Результат вызова getMe() в Telegram Bot API',
},
'TRIAL_USER_TAG': {
'description': (
'Тег, который бот передаст пользователю при активации триальной подписки в панели RemnaWave.'
+88
View File
@@ -184,6 +184,66 @@ def get_available_payment_methods() -> list[dict[str, str]]:
}
)
if settings.is_severpay_enabled():
severpay_name = settings.get_severpay_display_name()
methods.append(
{
'id': 'severpay',
'name': f'Банковская карта ({severpay_name})',
'icon': '💳',
'description': f'через {severpay_name}',
'callback': 'topup_severpay',
}
)
if settings.is_paypear_enabled():
paypear_name = settings.get_paypear_display_name()
methods.append(
{
'id': 'paypear',
'name': paypear_name,
'icon': '💳',
'description': f'через {paypear_name}',
'callback': 'topup_paypear',
}
)
if settings.is_rollypay_enabled():
rollypay_name = settings.get_rollypay_display_name()
methods.append(
{
'id': 'rollypay',
'name': rollypay_name,
'icon': '💳',
'description': f'через {rollypay_name}',
'callback': 'topup_rollypay',
}
)
if settings.is_overpay_enabled():
overpay_name = settings.get_overpay_display_name()
methods.append(
{
'id': 'overpay',
'name': overpay_name,
'icon': '💳',
'description': f'через {overpay_name}',
'callback': 'topup_overpay',
}
)
if settings.is_aurapay_enabled():
aurapay_name = settings.get_aurapay_display_name()
methods.append(
{
'id': 'aurapay',
'name': aurapay_name,
'icon': '💳',
'description': f'через {aurapay_name}',
'callback': 'topup_aurapay',
}
)
if settings.is_support_topup_enabled():
methods.append(
{
@@ -311,6 +371,16 @@ def is_payment_method_available(method_id: str) -> bool:
return settings.is_kassa_ai_enabled()
if method_id == 'riopay':
return settings.is_riopay_enabled()
if method_id == 'severpay':
return settings.is_severpay_enabled()
if method_id == 'paypear':
return settings.is_paypear_enabled()
if method_id == 'rollypay':
return settings.is_rollypay_enabled()
if method_id == 'overpay':
return settings.is_overpay_enabled()
if method_id == 'aurapay':
return settings.is_aurapay_enabled()
if method_id == 'support':
return settings.is_support_topup_enabled()
return False
@@ -333,6 +403,12 @@ def get_payment_method_status() -> dict[str, bool]:
'cloudpayments': settings.is_cloudpayments_enabled(),
'freekassa': settings.is_freekassa_enabled(),
'kassa_ai': settings.is_kassa_ai_enabled(),
'riopay': settings.is_riopay_enabled(),
'severpay': settings.is_severpay_enabled(),
'paypear': settings.is_paypear_enabled(),
'rollypay': settings.is_rollypay_enabled(),
'overpay': settings.is_overpay_enabled(),
'aurapay': settings.is_aurapay_enabled(),
'support': settings.is_support_topup_enabled(),
}
@@ -366,4 +442,16 @@ def get_enabled_payment_methods_count() -> int:
count += 1
if settings.is_kassa_ai_enabled():
count += 1
if settings.is_riopay_enabled():
count += 1
if settings.is_severpay_enabled():
count += 1
if settings.is_paypear_enabled():
count += 1
if settings.is_rollypay_enabled():
count += 1
if settings.is_overpay_enabled():
count += 1
if settings.is_aurapay_enabled():
count += 1
return count
+3 -2
View File
@@ -1,3 +1,4 @@
import math
from collections.abc import Sequence
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Optional
@@ -19,14 +20,14 @@ def calculate_months_from_days(days: int) -> int:
return max(1, round(days / 30))
def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_days: int = 30) -> tuple[int, int]:
def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_days: int = 1) -> tuple[int, int]:
"""Calculate prorated price based on remaining days.
Returns:
tuple of (total_price_kopeks, days_charged)
"""
now = datetime.now(UTC)
days_remaining = max(1, (end_date - now).days)
days_remaining = max(1, math.ceil((end_date - now).total_seconds() / 86400))
days_to_charge = max(min_charge_days, days_remaining)
total_price = monthly_price * days_to_charge // 30
+4 -4
View File
@@ -5755,7 +5755,7 @@ async def update_subscription_servers_endpoint(
subscription.end_date,
)
else:
charged_days = max(1, (subscription.end_date - datetime.now(UTC)).days)
charged_days = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
added_server_ids = [catalog[uuid].get('server_id') for uuid in added if catalog[uuid].get('server_id') is not None]
added_server_prices = [
@@ -5933,7 +5933,7 @@ async def update_subscription_traffic_endpoint(
},
)
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
period_hint_days = days_remaining
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
@@ -6111,7 +6111,7 @@ async def update_subscription_devices_endpoint(
chargeable_diff = new_chargeable - current_chargeable
price_per_month = chargeable_diff * tariff_device_price
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
period_hint_days = days_remaining
# Lock user BEFORE price computation to prevent TOCTOU on promo discount
@@ -6159,7 +6159,7 @@ async def update_subscription_devices_endpoint(
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price_to_charge,
description=f'{description} за {charged_days or max(1, (subscription.end_date - datetime.now(UTC)).days)} дн.',
description=f'{description} за {charged_days or max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))} дн.',
)
if price_to_charge > 0:
+7 -2
View File
@@ -1268,8 +1268,13 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
from app.services.paypear_service import paypear_service
if not paypear_service.verify_webhook_signature(raw_body, received_signature):
logger.warning('PayPear webhook: invalid signature')
client_ip = (
request.headers.get('x-real-ip')
or request.headers.get('x-forwarded-for', '').split(',')[0].strip()
or (request.client.host if request.client else None)
)
if not paypear_service.verify_webhook_signature(raw_body, received_signature, client_ip=client_ip):
logger.warning('PayPear webhook: invalid signature and IP', client_ip=client_ip)
return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN)
try:
-3
View File
@@ -402,9 +402,6 @@
- `app/services/campaign_service.py` — Python-модуль
Классы: `CampaignBonusResult`, `AdvertisingCampaignService` (1 методов)
Функции: нет
- `app/services/external_admin_service.py` — Утилиты для синхронизации токена внешней админки.
Классы: нет
Функции: нет
- `app/services/faq_service.py` — Python-модуль
Классы: `FaqService` (3 методов)
Функции: нет
-19
View File
@@ -22,7 +22,6 @@ from app.services.ban_notification_service import ban_notification_service
from app.services.broadcast_service import broadcast_service
from app.services.contest_rotation_service import contest_rotation_service
from app.services.daily_subscription_service import daily_subscription_service
from app.services.external_admin_service import ensure_external_admin_token
from app.services.log_rotation_service import log_rotation_service
from app.services.maintenance_service import maintenance_service
from app.services.monitoring_service import monitoring_service
@@ -515,24 +514,6 @@ async def main():
else:
stage.skip('NaloGO отключен настройками')
async with timeline.stage(
'Внешняя админка',
'🛡️',
success_message='Токен внешней админки готов',
) as stage:
try:
token = await ensure_external_admin_token(
bot_user.username,
bot_user.id,
)
if token:
stage.log('Токен синхронизирован')
else:
stage.warning('Не удалось получить токен внешней админки')
except Exception as error: # pragma: no cover - защитный блок
stage.warning(f'Ошибка подготовки внешней админки: {error}')
logger.error('❌ Ошибка подготовки внешней админки', error=error)
bot_run_mode = settings.get_bot_run_mode()
polling_enabled = bot_run_mode == 'polling'
telegram_webhook_enabled = bot_run_mode == 'webhook'
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.52.1"
version = "3.53.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }