Compare commits

...

32 Commits

Author SHA1 Message Date
c0mrade eef41c4bca Merge pull request #2846 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.45.0
2026-04-03 19:13:53 +03:00
github-actions[bot] 987c3c93c2 chore(main): release 3.45.0 2026-04-03 16:12:28 +00:00
c0mrade 7d24e8d704 Merge pull request #2845 from BEDOLAGA-DEV/dev
fix: subscription system bugfixes + torrent notifications + user deletion cleanup
2026-04-03 19:12:06 +03:00
c0mrade 819f09a68e fix: restore missing import + rewrite user.deleted webhook to properly deactivate all subscriptions
- Fix NameError in admin_users.py: re-add get_traffic_reset_strategy import that ruff auto-removed
- user.deleted: remove auto-recreation logic — deleted means deleted, no more recreating users back in panel
- user.deleted: deactivate primary subscription unconditionally (expire + clear all linkage)
- user.deleted: sweep sibling subscriptions — verify each via panel API, deactivate only those whose panel user is gone (safe for multi-tariff where only one of N panel users may be deleted)
- Works across multi-tariff, single-tariff, and classic modes
2026-04-03 18:56:14 +03:00
c0mrade 2f9d00343b feat: send torrent blocker notification to user (not just admin)
- torrent_blocker.report is now a dual event: admin notification + user message
- New _handle_torrent_detected user handler sends WEBHOOK_TORRENT_DETECTED
- process_event handles events registered in both admin and user handlers
- Webhook router passes DB session for dual events (needs_db_session check)
- Add WEBHOOK_NOTIFY_TORRENT_DETECTED setting (default: true)
- Add WEBHOOK_TORRENT_DETECTED locale texts (ru/en/ua/zh/fa)
2026-04-03 18:19:45 +03:00
c0mrade 9b7ac47f16 fix: resolve multiple subscription bugs — LIMITED status, trial tariff blocking, traffic reset strategy, classic mode pricing, 100% discount support
- Include LIMITED status in subscription lookups (get_active_subscriptions_by_user_id, get_subscription_by_user_and_tariff) — fixes duplicate subscriptions when traffic exhausted
- Migration 0053: update partial unique index to include LIMITED
- Trial subscriptions no longer block tariff purchase — excluded from purchased_tariff_ids, handle_extend_subscription routes trial+tariff to tariff extend flow
- Replace hardcoded TrafficLimitStrategy.MONTH with get_traffic_reset_strategy() across all sync/create paths (remnawave_service, monitoring_service, admin_users)
- Subscriptions with tariff_id always use tariff pricing flow regardless of global sales mode — fixes 0₽ renewal in classic mode
- Support 100% promo group discount across all purchase/renewal flows — balance checks skip when price=0, validation allows final_total=0 when base_price>0
2026-04-03 17:22:42 +03:00
Egor 0d5638f778 Merge pull request #2838 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.44.0
2026-04-02 07:24:58 +03:00
github-actions[bot] 7836720db3 chore(main): release 3.44.0 2026-04-02 04:24:32 +00:00
Egor dcb90d6139 Merge pull request #2837 from BEDOLAGA-DEV/dev
Dev
2026-04-02 07:24:07 +03:00
Fringg 96c420e917 style: fix ruff format for severpay.py 2026-04-02 07:17:36 +03:00
Fringg 9d63635502 feat: add SberPay as KassaAI sub-method (payment_system_id=43)
Adds SberPay alongside existing SBP (44) and Card (36) sub-methods.

Changes across all 8 required files:
- config.py: KASSA_AI_SBERPAY_ENABLED, display name, helper methods
- kassa_ai_service.py: payment_system_id=43 in KASSA_AI_SUB_METHODS
- kassa_ai.py handler: method config + start_kassa_ai_sberpay_topup
- main.py: route tuple, import, handler registration
- payment_service.py: guest purchase flow method check
- cabinet/balance.py: KASSA_AI_OPTION_MAP sberpay=43
- payment_method_config_service.py: sub_option for admin panel
- inline.py: keyboard button + fallback condition guard

Env vars: KASSA_AI_SBERPAY_ENABLED, KASSA_AI_SBERPAY_DISPLAY_NAME
2026-04-02 06:47:57 +03:00
Egor c4c5d330be Merge pull request #2836 from BEDOLAGA-DEV/main
w
2026-04-02 06:39:28 +03:00
Fringg 977950b97f fix: address review issues in PR #2829 webhook intentional deletion guard
5 issues found by review agents and fixed:

1. Intentional deletion guard was at top of process_event, skipping ALL
   cleanup (subscription URLs, server counts, expired marking). Moved
   check into _handle_user_deleted so cleanup still runs but re-creation
   is suppressed via subscription_still_valid=False.

2. Variable shadowing: telegram_id loop var in any() generator shadowed
   the outer telegram_id local. Renamed to tid/uid.

3. No hard cap on in-memory dicts: added _MAX_INTENTIONAL_ENTRIES=10000
   with early return in mark_intentional_panel_deletion.

4. mark_intentional_panel_deletion called inside for-loop with single
   UUID — race window if webhook from first delete arrives before
   second UUID is marked. Moved to before the loop with all UUIDs.

5. Tests: @pytest.mark.anyio → @pytest.mark.anyio('asyncio') for
   consistency. Replaced process_event(db=None) test with direct
   mark+detect unit tests + hard cap test.
2026-04-02 06:34:35 +03:00
Fringg 6f6b9fa039 Merge branch 'yazhog/main' into dev 2026-04-02 06:30:45 +03:00
Fringg 6713921887 fix: Pal24 card/sbp option not passed to API in cabinet balance topup
The payment_option (card/sbp) was parsed from the request but never
passed to create_pal24_payment as payment_method. Without it,
_normalize_payment_method(None) defaults to 'sbp', so both Card and
SBP buttons always created SBP payments.
2026-04-02 06:20:21 +03:00
Fringg 2d42152f54 fix: NameError in SeverPay guest payment flow
user variable was unbound when user_id=None (guest purchase path).
Added user=None in the else branch to prevent NameError when
constructing the fallback email.
2026-04-02 06:18:37 +03:00
Fringg 08ca947b2b fix: send telegram_id@telegram.org as email to SeverPay
SeverPay requires a non-empty client_email field. When user has no
email, fallback to {telegram_id}@telegram.org instead of empty string.
2026-04-02 06:16:17 +03:00
Fringg b04157c913 fix: notification sent for non-deactivated subs + webhook race condition
Two fixes for channel subscription enforcement:

1. Middleware notification guard: the deactivation notification was sent
   even when deactivated_subs was empty (no subs actually deactivated).
   Also fixed len(active_subs) -> len(deactivated_subs) for multi-tariff
   notification text selection.

2. Webhook echo race condition: when a user quickly leaves and rejoins
   a channel, the delayed user.disabled webhook from RemnaWave could
   re-deactivate a subscription that was already reactivated.
   Fix: stamp last_webhook_update_at on reactivation (both channel_member
   handler and middleware), then guard _handle_user_disabled against
   re-deactivating recently-reactivated ACTIVE subscriptions using the
   existing is_recently_updated_by_webhook (60s window).
2026-04-02 06:14:45 +03:00
Fringg f284351c51 fix: middleware disables panel VPN for all subs ignoring per-channel settings
In _deactivate_subscription_on_unsubscribe, the loop calling
disable_remnawave_user iterated over all active_subs instead of only
the subscriptions that passed the should_disable_subscription check.

This caused paid subscriptions to be disabled at the RemnaWave panel
level even when disable_paid_on_leave=False, while the DB record
stayed ACTIVE. On rejoin, reactivation found no DISABLED subs in DB
so enable_remnawave_user was never called — VPN stayed off permanently.

Fixed by collecting actually-deactivated subs into a separate list
and using that for both panel disable calls and notifications.
2026-04-02 06:09:39 +03:00
Fringg b607993854 fix: prevent nested state saves and None state loss in promo handler
Two bugs found during review of the previous FSM state restore fix:

1. Re-entering promo flow created nested _prev_data (unbounded growth).
   Now skips save if already in PromoCodeStates.waiting_for_code and
   strips _prev_ keys from saved data to prevent nesting.

2. _restore_previous_state used `if prev_state:` which treated saved
   None state (user at menu) same as "no saved state". Now uses a
   sentinel to distinguish the two cases, correctly restoring None
   state with its data instead of calling state.clear().
2026-04-02 05:59:28 +03:00
Fringg 246659032d fix: promo code activation destroys balance input FSM state
When a user was in BalanceStates.waiting_for_amount and activated a
promo code, process_promocode called state.clear() on all exit paths,
wiping the balance state. Typing the amount then hit the fallback
"Не понимаю эту команду" handler.

Now show_promocode_menu saves the previous FSM state/data before
entering promo flow, and _restore_previous_state restores it after
promo code processing completes.
2026-04-02 05:55:29 +03:00
Fringg 3dc72b00e7 fix: send telegram_id@telegram.org as email to Kassa AI
Kassa AI requires email in format {telegram_id}@telegram.org but we
were sending user_{order_id}@telegram.org as fallback when email was
not provided. Now the fallback uses user.telegram_id directly.
2026-04-02 05:49:01 +03:00
Fringg 033d0da5e0 fix: remove non-existent Platega method code 10, rename 11 to Карты (RUB)
Platega API defines: 2=СБП, 11=Карточный эквайринг, 12=Международная,
13=Крипто. Code 10 does not exist in their API but was defined in our
config as "Банковские карты (RUB)", while real code 11 was mislabeled
as "Банковские карты". This caused two card options to appear in the
admin panel, one of which didn't work.

- Removed code 10 from definitions, defaults, allowed set, .env.example
- Renamed code 11: "Банковские карты" → "Карты (RUB)"
- Removed redundant filter in handlers/balance/platega.py
- Updated tests to match
2026-04-02 05:44:24 +03:00
Fringg 991f0b43e1 fix: autopay failure notifications ignoring 6h cooldown
Two bugs caused users to receive autopay error notifications every
monitoring cycle (hourly) instead of respecting the 6-hour cooldown:

1. subtract_user_balance failure path had no cooldown check at all
2. cache.exists() silently returns False when Redis is disconnected,
   bypassing the try/except cooldown guard

Extracted shared _check_autopay_fail_cooldown / _set_autopay_fail_cooldown
methods with in-memory fallback dict that works even without Redis.
Added cleanup of expired in-memory entries in _cleanup_notification_cache.
2026-04-02 05:44:13 +03:00
c0mrade c9524cb703 Merge pull request #2832 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.43.1
2026-03-31 20:24:44 +03:00
github-actions[bot] 76d4a2124c chore(main): release 3.43.1 2026-03-31 17:20:24 +00:00
c0mrade 4fa230c07f Merge pull request #2831 from BEDOLAGA-DEV/dev
Release: dev → main
2026-03-31 20:19:53 +03:00
c0mrade d580a78403 Merge remote-tracking branch 'origin/main' into dev 2026-03-31 15:13:46 +03:00
c0mrade 312cc728a9 docs: add Platega partnership to README, highlight partner payment providers 2026-03-31 13:04:40 +03:00
yazhog b1820c651d Fix RemnaWave webhook deletion race 2026-03-30 15:56:45 +03:00
c0mrade 0c284b9e99 fix: use subscription-level remnawave_uuid in multi-tariff mode for sync and detail pages
In multi-tariff mode, remnawave_uuid lives on the subscription object,
not the user. The sync status and user detail endpoints were always
returning user.remnawave_uuid, causing some users to see no UUID.
2026-03-30 14:41:58 +03:00
c0mrade 72170b35f5 fix: prevent MissingGreenlet on subscription.tariff lazy load in webhook handlers
Replace unsafe getattr(subscription, 'tariff', None) with sa_inspect().dict.get()
to avoid triggering lazy loads after db.commit()/refresh() in async context.
2026-03-29 17:31:38 +03:00
53 changed files with 886 additions and 293 deletions
+1 -1
View File
@@ -614,7 +614,7 @@ PLATEGA_RETURN_URL=
PLATEGA_FAILED_URL=
PLATEGA_CURRENCY=RUB
# Список ID активных методов из кабинета Platega (через запятую)
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
PLATEGA_ACTIVE_METHODS=2,11,12,13
PLATEGA_MIN_AMOUNT_KOPEKS=100
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
PLATEGA_WEBHOOK_PATH=/platega-webhook
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.43.0"
".": "3.45.0"
}
+49
View File
@@ -1,5 +1,54 @@
# Changelog
## [3.45.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.44.0...v3.45.0) (2026-04-03)
### New Features
* send torrent blocker notification to user (not just admin) ([2f9d003](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f9d00343bee2980cc89bd24361259073b97127a))
### Bug Fixes
* resolve multiple subscription bugs — LIMITED status, trial tariff blocking, traffic reset strategy, classic mode pricing, 100% discount support ([9b7ac47](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9b7ac47f16076e546da62062ff7ce18d7c308988))
* restore missing import + rewrite user.deleted webhook to properly deactivate all subscriptions ([819f09a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/819f09a68ec95237294bae97f31c644044a3623f))
* subscription system bugfixes + torrent notifications + user deletion cleanup ([7d24e8d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d24e8d7047c7a3a1c417e655a6fbccbe5ae577d))
## [3.44.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.43.1...v3.44.0) (2026-04-02)
### New Features
* add SberPay as KassaAI sub-method (payment_system_id=43) ([9d63635](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d636355026ad1e50d045e78ffa21e76cfef0774))
### Bug Fixes
* address review issues in PR [#2829](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2829) webhook intentional deletion guard ([977950b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/977950b97f07eecf089152d3f4e678fda373e1e6))
* autopay failure notifications ignoring 6h cooldown ([991f0b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/991f0b43e1e73446690a4fbec7c5c5642ac8c406))
* middleware disables panel VPN for all subs ignoring per-channel settings ([f284351](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f284351c51a6843db0771a92338ec770d5f0d8d2))
* NameError in SeverPay guest payment flow ([2d42152](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2d42152f5491b14cc45388e0ffccf8a61848a2f6))
* notification sent for non-deactivated subs + webhook race condition ([b04157c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b04157c91327d9031e9f603a6ad33c708e27d753))
* Pal24 card/sbp option not passed to API in cabinet balance topup ([6713921](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67139218878dca3e75974eb5b5a2ce91d5b1438e))
* prevent nested state saves and None state loss in promo handler ([b607993](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b607993854d1374e7d7c2afbb7fe5cc8824732f5))
* promo code activation destroys balance input FSM state ([2466590](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/246659032de812f1d4502029ab139f3104237d5c))
* remove non-existent Platega method code 10, rename 11 to Карты (RUB) ([033d0da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/033d0da5e0033a2310431586a291b529e3ccb89a))
* send telegram_id@telegram.org as email to Kassa AI ([3dc72b0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3dc72b00e751a69966d2d5830492c82e055b72e6))
* send telegram_id@telegram.org as email to SeverPay ([08ca947](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/08ca947b2b2bb29782c86e7b5d6bea71e2811751))
## [3.43.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.43.0...v3.43.1) (2026-03-31)
### Bug Fixes
* prevent MissingGreenlet on subscription.tariff lazy load in webhook handlers ([72170b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/72170b35f5d2af56aa7dcb579a70ecf6af2da3f6))
* use subscription-level remnawave_uuid in multi-tariff mode for sync and detail pages ([0c284b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c284b9e9941b516170fc68a3f63551096cb5a7b))
### Documentation
* add Platega partnership to README, highlight partner payment providers ([312cc72](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/312cc728a9321fe9ac90cf1f5201e38465f67f16))
## [3.43.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.42.0...v3.43.0) (2026-03-29)
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.43.0" # x-release-please-version
ARG VERSION="v3.45.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+16 -2
View File
@@ -118,8 +118,8 @@ Bedolaga — полнофункциональная платформа для п
| 💳 | **Freekassa** | NSPK СБП, карты | RUB |
| 💳 | **Kassa AI** | СБП, карты, SberPay | RUB |
| 💳 | **PayPalych (Pal24)** | Карты, СБП | RUB |
| 💳 | **Platega** | Карты, СБП, крипто | RUB |
| 💳 | **WATA** | СБП, Карты | RUB |
| 🤝 | **[Platega](https://t.me/ArstanPlatega)** 🔸 | Карты, СБП, крипто | RUB |
| 🤝 | **[WATA](https://t.me/wyrz_wata)** 🔸 | СБП, Карты | RUB |
| 💳 | **MulenPay** | Карты | RUB |
| 💳 | **RioPay** | Карты | RUB |
| 💳 | **SeverPay** | СБП, карты | RUB |
@@ -127,6 +127,8 @@ Bedolaga — полнофункциональная платформа для п
</div>
> 🔸 — официальный партнёр Bedolaga (особые условия по кодовому слову **`bedolaga`**)
>
> Все провайдеры работают параллельно через единый веб-сервер на порту 8080. Подробная настройка — в [документации](https://docs.bedolagam.ru/bot/payments).
<div align="center">
@@ -134,6 +136,18 @@ Bedolaga — полнофункциональная платформа для п
<tr>
<td align="center">
<img src=".github/assets/platega-logo.jpg" alt="Platega" width="60" />
**🤝 Официальный партнёр Platega**
Bedolaga — официальный партнёр платёжной системы **Platega**.<br>
Пользователи бота получают **особые условия** при подключении по кодовому слову **`bedolaga`**
📩 По вопросам: [@ArstanPlatega](https://t.me/ArstanPlatega)
</td>
<td align="center">
<img src=".github/assets/wata-logo.jpg" alt="WATA" width="60" />
**🤝 Официальный партнёр WATA**
+23 -17
View File
@@ -236,8 +236,9 @@ async def _sync_subscription_to_panel(
"""
try:
from app.config import settings
from app.external.remnawave_api import TrafficLimitStrategy, UserStatus as PanelUserStatus
from app.external.remnawave_api import UserStatus as PanelUserStatus
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import get_traffic_reset_strategy
from app.utils.subscription_utils import resolve_hwid_device_limit_for_payload
service = RemnaWaveService()
@@ -323,7 +324,7 @@ async def _sync_subscription_to_panel(
'uuid': panel_uuid,
'status': panel_status,
'traffic_limit_bytes': traffic_limit_bytes,
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
'traffic_limit_strategy': get_traffic_reset_strategy(subscription.tariff),
'description': description,
}
if expire_at:
@@ -358,7 +359,7 @@ async def _sync_subscription_to_panel(
'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)),
'status': panel_status,
'traffic_limit_bytes': traffic_limit_bytes,
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
'traffic_limit_strategy': get_traffic_reset_strategy(subscription.tariff),
'telegram_id': user.telegram_id,
'email': user.email,
'description': description,
@@ -709,7 +710,11 @@ async def get_user_detail(
promo_offer_discount_source=user.promo_offer_discount_source,
promo_offer_discount_expires_at=user.promo_offer_discount_expires_at,
recent_transactions=recent_transactions,
remnawave_uuid=user.remnawave_uuid,
remnawave_uuid=(
primary_sub.remnawave_uuid
if settings.is_multi_tariff_enabled() and primary_sub and primary_sub.remnawave_uuid
else user.remnawave_uuid
),
)
@@ -2681,6 +2686,13 @@ async def get_user_sync_status(
bot_device_limit = active_sub.device_limit or 0
bot_squads = active_sub.connected_squads or []
# In multi-tariff mode, UUID lives on subscription, not user
effective_uuid = (
active_sub.remnawave_uuid
if settings.is_multi_tariff_enabled() and active_sub and active_sub.remnawave_uuid
else user.remnawave_uuid
)
# Panel data
panel_found = False
panel_status = None
@@ -2699,16 +2711,9 @@ async def get_user_sync_status(
async with service.get_api_client() as api:
panel_user = None
# In multi-tariff mode, UUID lives on subscription, not user
sync_uuid = (
active_sub.remnawave_uuid
if settings.is_multi_tariff_enabled() and active_sub and active_sub.remnawave_uuid
else user.remnawave_uuid
)
# Try by UUID first (works for all users including OAuth)
if sync_uuid:
panel_user = await api.get_user_by_uuid(sync_uuid)
if effective_uuid:
panel_user = await api.get_user_by_uuid(effective_uuid)
# Fallback: search by telegram_id
if not panel_user and user.telegram_id:
@@ -2800,7 +2805,7 @@ async def get_user_sync_status(
return PanelSyncStatusResponse(
user_id=user.id,
telegram_id=user.telegram_id,
remnawave_uuid=user.remnawave_uuid,
remnawave_uuid=effective_uuid,
last_sync=user.last_remnawave_sync,
subscription_id=active_sub.id if active_sub else None,
subscription_tariff_name=sub_tariff_name,
@@ -3114,8 +3119,9 @@ async def sync_user_to_panel(
try:
from app.config import settings
from app.external.remnawave_api import TrafficLimitStrategy, UserStatus as PanelUserStatus
from app.external.remnawave_api import UserStatus as PanelUserStatus
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import get_traffic_reset_strategy
from app.utils.subscription_utils import resolve_hwid_device_limit_for_payload
service = RemnaWaveService()
@@ -3214,7 +3220,7 @@ async def sync_user_to_panel(
if request.update_traffic_limit:
update_kwargs['traffic_limit_bytes'] = traffic_limit_bytes
update_kwargs['traffic_limit_strategy'] = TrafficLimitStrategy.MONTH
update_kwargs['traffic_limit_strategy'] = get_traffic_reset_strategy(sub.tariff)
changes['traffic_limit_gb'] = sub.traffic_limit_gb
if request.update_squads and sub.connected_squads:
@@ -3248,7 +3254,7 @@ async def sync_user_to_panel(
'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)),
'status': panel_status,
'traffic_limit_bytes': traffic_limit_bytes,
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
'traffic_limit_strategy': get_traffic_reset_strategy(sub.tariff),
'telegram_id': user.telegram_id,
'email': user.email,
'description': description,
+2 -1
View File
@@ -578,6 +578,7 @@ async def create_topup(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method=option,
)
if result:
@@ -698,7 +699,7 @@ async def create_topup(
)
# Use payment_option to select sbp or card
KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36}
KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36, 'sberpay': 43}
option = (request.payment_option or '').strip().lower()
ps_id = KASSA_AI_OPTION_MAP.get(option) # None = use env default
+2 -2
View File
@@ -425,8 +425,8 @@ async def create_gift_purchase(
warning=recipient_warning,
)
# Balance mode
if user.balance_kopeks < price_kopeks:
# Balance mode (skip for 100% discount)
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Insufficient balance',
@@ -134,8 +134,8 @@ async def purchase_devices_legacy(
detail=f'Максимальное количество устройств: {max_device_limit}',
)
# Check balance
if user.balance_kopeks < total_price:
# Check balance (skip for 100% discount)
if total_price > 0 and user.balance_kopeks < total_price:
missing = total_price - user.balance_kopeks
# Сохраняем корзину для автопокупки после пополнения
@@ -375,8 +375,8 @@ async def purchase_devices(
if devices_discount_percent < 100:
price_kopeks = max(100, price_kopeks)
# Check balance
if user.balance_kopeks < price_kopeks:
# Check balance (skip for 100% discount)
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
# Сохраняем корзину для автопокупки после пополнения
@@ -304,9 +304,7 @@ async def get_purchase_options(
from app.database.crud.subscription import get_active_subscriptions_by_user_id
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
purchased_tariff_ids = {
s.tariff_id for s in active_subs if s.tariff_id and s.status in ('active', 'trial')
}
purchased_tariff_ids = {s.tariff_id for s in active_subs if s.tariff_id and not s.is_trial}
if subscription_id:
from app.database.crud.subscription import get_subscription_by_id_for_user
@@ -686,7 +684,7 @@ async def purchase_tariff(
)
# Check balance
if user.balance_kopeks < price_kopeks:
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
# Save cart for auto-purchase after balance top-up
@@ -1158,7 +1156,7 @@ async def activate_trial(
from app.database.crud.user import subtract_user_balance
price_kopeks = settings.TRIAL_ACTIVATION_PRICE
if user.balance_kopeks < price_kopeks:
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Insufficient balance. Need {price_kopeks / 100:.2f} RUB',
@@ -168,8 +168,8 @@ async def renew_subscription(
tariff = subscription.tariff if subscription.tariff_id else None
# Check balance
if user.balance_kopeks < price_kopeks:
# Check balance (skip for 100% discount)
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
# Get tariff info for cart
@@ -254,7 +254,7 @@ async def purchase_traffic(
final_price = max(100, final_price)
# Проверяем баланс
if user.balance_kopeks < final_price:
if final_price > 0 and user.balance_kopeks < final_price:
missing = final_price - user.balance_kopeks
# Save cart for auto-purchase after balance top-up
@@ -560,7 +560,7 @@ async def switch_traffic_package(
# Prorated calculation
final_price, days_charged = calculate_prorated_price(price_diff, subscription.end_date)
if user.balance_kopeks < final_price:
if final_price > 0 and user.balance_kopeks < final_price:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f'Insufficient balance. Need {final_price / 100:.2f} RUB',
+16 -4
View File
@@ -133,6 +133,7 @@ class Settings(BaseSettings):
WEBHOOK_NOTIFY_NOT_CONNECTED: bool = True
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD: bool = True
WEBHOOK_NOTIFY_DEVICES: bool = True
WEBHOOK_NOTIFY_TORRENT_DETECTED: bool = True
TRIAL_DURATION_DAYS: int = 3
TRIAL_TRAFFIC_LIMIT_GB: int = 10
@@ -467,7 +468,7 @@ class Settings(BaseSettings):
PLATEGA_RETURN_URL: str | None = None
PLATEGA_FAILED_URL: str | None = None
PLATEGA_CURRENCY: str = 'RUB'
PLATEGA_ACTIVE_METHODS: str = '2,10,11,12,13'
PLATEGA_ACTIVE_METHODS: str = '2,11,12,13'
PLATEGA_INLINE_METHODS: bool = True
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
@@ -558,6 +559,8 @@ class Settings(BaseSettings):
KASSA_AI_SBP_DISPLAY_NAME: str = 'СБП (KassaAI)'
KASSA_AI_CARD_ENABLED: bool = False # Карты РФ — payment_system_id=36
KASSA_AI_CARD_DISPLAY_NAME: str = 'Карта (KassaAI)'
KASSA_AI_SBERPAY_ENABLED: bool = False # SberPay — payment_system_id=43
KASSA_AI_SBERPAY_DISPLAY_NAME: str = 'SberPay (KassaAI)'
# RioPay (api.riopay.online) v2.0.1
RIOPAY_ENABLED: bool = False
@@ -1839,7 +1842,7 @@ class Settings(BaseSettings):
except ValueError:
logger.warning('Некорректный код метода Platega', part=part)
continue
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
if method_code in {2, 11, 12, 13} and method_code not in seen:
methods.append(method_code)
seen.add(method_code)
@@ -1852,8 +1855,7 @@ class Settings(BaseSettings):
def get_platega_method_definitions() -> dict[int, dict[str, str]]:
return {
2: {'name': 'СБП (QR)', 'title': '🏦 СБП (QR)'},
10: {'name': 'Банковские карты (RUB)', 'title': '💳 Карты (RUB)'},
11: {'name': 'Банковские карты', 'title': '💳 Банковские карты'},
11: {'name': 'Карты (RUB)', 'title': '💳 Карты (RUB)'},
12: {'name': 'Международные карты', 'title': '🌍 Международные карты'},
13: {'name': 'Криптовалюта', 'title': '🪙 Криптовалюта'},
}
@@ -1981,6 +1983,16 @@ class Settings(BaseSettings):
def get_kassa_ai_card_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_card_display_name())
def is_kassa_ai_sberpay_enabled(self) -> bool:
return self.KASSA_AI_SBERPAY_ENABLED and self.is_kassa_ai_enabled()
def get_kassa_ai_sberpay_display_name(self) -> str:
name = (self.KASSA_AI_SBERPAY_DISPLAY_NAME or '').strip()
return name if name else 'SberPay (KassaAI)'
def get_kassa_ai_sberpay_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_sberpay_display_name())
def is_payment_verification_auto_check_enabled(self) -> bool:
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
+25 -4
View File
@@ -2075,7 +2075,12 @@ async def toggle_daily_subscription_pause(
async def get_active_subscriptions_by_user_id(db: AsyncSession, user_id: int) -> list[Subscription]:
"""Get all active/trial subscriptions for a user."""
"""Get all active/trial/limited subscriptions for a user.
Includes LIMITED status because those subscriptions still have time remaining
(just ran out of traffic) and should be treated as "alive" for renewal,
duplicate prevention, and display purposes.
"""
result = await db.execute(
select(Subscription)
.options(
@@ -2084,7 +2089,13 @@ async def get_active_subscriptions_by_user_id(db: AsyncSession, user_id: int) ->
)
.where(
Subscription.user_id == user_id,
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
Subscription.status.in_(
[
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
SubscriptionStatus.LIMITED.value,
]
),
)
.order_by(Subscription.created_at.desc())
)
@@ -2121,7 +2132,11 @@ async def get_subscription_by_id(db: AsyncSession, subscription_id: int) -> Subs
async def get_subscription_by_user_and_tariff(db: AsyncSession, user_id: int, tariff_id: int) -> Subscription | None:
"""Get active/trial subscription for a specific user+tariff combination."""
"""Get active/trial/limited subscription for a specific user+tariff combination.
Includes LIMITED status because those subscriptions still have time remaining
(just ran out of traffic) and should be extended rather than duplicated.
"""
result = await db.execute(
select(Subscription)
.options(
@@ -2131,7 +2146,13 @@ async def get_subscription_by_user_and_tariff(db: AsyncSession, user_id: int, ta
.where(
Subscription.user_id == user_id,
Subscription.tariff_id == tariff_id,
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
Subscription.status.in_(
[
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
SubscriptionStatus.LIMITED.value,
]
),
)
.order_by(Subscription.created_at.desc())
.limit(1)
+8 -5
View File
@@ -4162,14 +4162,16 @@ async def _update_user_traffic(
) or getattr(user, 'remnawave_uuid', None)
if _uuid:
try:
from app.external.remnawave_api import TrafficLimitStrategy
from app.services.subscription_service import get_traffic_reset_strategy
remnawave_service = RemnaWaveService()
async with remnawave_service.get_api_client() as api:
await api.update_user(
uuid=_uuid,
traffic_limit_bytes=traffic_gb * (1024**3) if traffic_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(
subscription.tariff if subscription else None
),
description=settings.format_remnawave_user_description(
full_name=user.full_name, username=user.username, telegram_id=user.telegram_id
),
@@ -4877,8 +4879,9 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
)
try:
from app.external.remnawave_api import TrafficLimitStrategy, UserStatus
from app.external.remnawave_api import UserStatus
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import get_traffic_reset_strategy
remnawave_service = RemnaWaveService()
@@ -4903,7 +4906,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3)
if subscription.traffic_limit_gb > 0
else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
description=settings.format_remnawave_user_description(
full_name=target_user.full_name,
username=target_user.username,
@@ -4939,7 +4942,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3)
if subscription.traffic_limit_gb > 0
else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
telegram_id=target_user.telegram_id,
email=target_user.email,
description=settings.format_remnawave_user_description(
+16
View File
@@ -39,6 +39,11 @@ _KASSA_AI_METHOD_CONFIG = {
'display_name': settings.get_kassa_ai_card_display_name,
'unavailable_text': 'KassaAI Карта временно недоступна',
},
'kassa_ai_sberpay': {
'is_enabled': settings.is_kassa_ai_sberpay_enabled,
'display_name': settings.get_kassa_ai_sberpay_display_name,
'unavailable_text': 'KassaAI SberPay временно недоступен',
},
}
@@ -350,3 +355,14 @@ async def start_kassa_ai_card_topup(
):
"""Start KassaAI Card top-up process."""
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_card')
@error_handler
async def start_kassa_ai_sberpay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Start KassaAI SberPay top-up process."""
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_sberpay')
+3 -1
View File
@@ -133,7 +133,7 @@ async def route_payment_by_method(
)
return True
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card'):
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card', 'kassa_ai_sberpay'):
from .kassa_ai import process_kassa_ai_payment_amount
async with AsyncSessionLocal() as db:
@@ -701,6 +701,7 @@ def register_balance_handlers(dp: Dispatcher):
from .kassa_ai import (
start_kassa_ai_card_topup,
start_kassa_ai_sberpay_topup,
start_kassa_ai_sbp_topup,
start_kassa_ai_topup,
)
@@ -708,6 +709,7 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_kassa_ai_topup, F.data == 'topup_kassa_ai')
dp.callback_query.register(start_kassa_ai_sbp_topup, F.data == 'topup_kassa_ai_sbp')
dp.callback_query.register(start_kassa_ai_card_topup, F.data == 'topup_kassa_ai_card')
dp.callback_query.register(start_kassa_ai_sberpay_topup, F.data == 'topup_kassa_ai_sberpay')
from .riopay import start_riopay_topup
+1 -2
View File
@@ -20,8 +20,7 @@ logger = structlog.get_logger(__name__)
def _get_active_methods() -> list[int]:
methods = settings.get_platega_active_methods()
return [code for code in methods if code in {2, 10, 11, 12, 13}]
return settings.get_platega_active_methods()
async def _prompt_amount(
+3
View File
@@ -78,6 +78,9 @@ async def on_user_joined_channel(event: ChatMemberUpdated, bot: Bot) -> None:
for subscription in disabled_subs:
await reactivate_subscription(db, subscription)
# Ставим штамп чтобы webhook user.disabled (echo от нашего disable)
# не переотключил подписку при быстрой реподписке
subscription.last_webhook_update_at = datetime.now(UTC)
logger.info(
'Subscriptions reactivated via channel event',
telegram_id=user.id,
+36 -5
View File
@@ -34,7 +34,21 @@ async def show_promocode_menu(callback: types.CallbackQuery, db_user: User, stat
else:
raise
# Сохраняем предыдущее состояние, чтобы восстановить после промокода
previous_state = await state.get_state()
previous_data = await state.get_data()
# Не перезаписываем сохранённое состояние при повторном входе в промо-флоу
if previous_state == PromoCodeStates.waiting_for_code.state:
await callback.answer()
return
# Убираем мета-ключи чтобы не создавать вложенность
previous_data.pop('_prev_state', None)
previous_data.pop('_prev_data', None)
await state.set_state(PromoCodeStates.waiting_for_code)
await state.update_data(_prev_state=previous_state, _prev_data=previous_data)
await callback.answer()
@@ -75,6 +89,23 @@ async def activate_promocode_for_registration(
return result
_NO_SAVED_STATE = object()
async def _restore_previous_state(state: FSMContext) -> None:
"""Восстанавливает FSM-состояние, которое было до входа в промокод-флоу."""
data = await state.get_data()
prev_state = data.get('_prev_state', _NO_SAVED_STATE)
prev_data = data.get('_prev_data') or {}
if prev_state is _NO_SAVED_STATE:
# Не было сохранённого состояния — defensive clear
await state.clear()
else:
# Восстанавливаем предыдущее состояние (включая None = меню без FSM)
await state.set_state(prev_state)
await state.set_data(prev_data)
@error_handler
async def process_promocode(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession):
texts = get_texts(db_user.language)
@@ -108,7 +139,7 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
).format(cooldown=cooldown),
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
await _restore_previous_state(state)
return
# Лимит на стакинг (макс активаций в день)
@@ -120,7 +151,7 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
),
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
await _restore_previous_state(state)
return
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
@@ -131,7 +162,7 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
texts.PROMOCODE_SUCCESS.format(description=result['description']),
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
await _restore_previous_state(state)
elif result.get('error') == 'select_subscription':
# Multi-tariff: user needs to choose which subscription to apply days to
eligible = result.get('eligible_subscriptions', [])
@@ -156,7 +187,7 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await state.clear()
await _restore_previous_state(state)
else:
# Записываем неудачную попытку только для not_found (перебор)
if result['error'] == 'not_found':
@@ -188,7 +219,7 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
error_text = error_messages.get(result['error'], texts.PROMOCODE_INVALID)
await message.answer(error_text, reply_markup=get_back_keyboard(db_user.language))
await state.clear()
await _restore_previous_state(state)
async def handle_promo_subscription_select(
+2 -2
View File
@@ -441,7 +441,7 @@ async def handle_simple_subscription_pay_with_balance(
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0)
if user_balance_kopeks < total_required:
if total_required > 0 and user_balance_kopeks < total_required:
await callback.answer('❌ Недостаточно средств на балансе для оплаты подписки', show_alert=True)
return
@@ -2181,7 +2181,7 @@ async def confirm_simple_subscription_purchase(
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0)
if user_balance_kopeks < total_required:
if total_required > 0 and user_balance_kopeks < total_required:
await callback.answer('❌ Недостаточно средств на балансе для оплаты подписки', show_alert=True)
return
+1 -1
View File
@@ -861,7 +861,7 @@ async def confirm_add_countries_to_subscription(
if country['uuid'] in removed_countries:
removed_countries_names.append(html.escape(country['name']))
if new_countries and db_user.balance_kopeks < total_price:
if new_countries and total_price > 0 and db_user.balance_kopeks < total_price:
missing_kopeks = total_price - db_user.balance_kopeks
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
+1 -1
View File
@@ -1273,7 +1273,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
total_discount=total_discount / 100,
)
if db_user.balance_kopeks < price:
if price > 0 and db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
required_text = f'{texts.format_price(price)} (за {period_label})'
message_text = texts.t(
+50 -17
View File
@@ -1537,7 +1537,7 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext,
total_price = prepared_cart_data.get('total_price', 0)
if db_user.balance_kopeks < total_price:
if total_price > 0 and db_user.balance_kopeks < total_price:
missing_amount = total_price - db_user.balance_kopeks
insufficient_keyboard = get_insufficient_balance_keyboard_with_cart(
db_user.language,
@@ -1635,7 +1635,7 @@ async def handle_extend_subscription(
else:
subscription = db_user.subscription
if not subscription or subscription.is_trial:
if not subscription:
await callback.message.edit_text(
'🎯 <b>Пробный период заканчивается</b>\n\nЧтобы продолжить пользоваться VPN, выберите подходящий тариф.',
reply_markup=types.InlineKeyboardMarkup(
@@ -1654,24 +1654,53 @@ async def handle_extend_subscription(
await callback.answer()
return
# В режиме тарифов проверяем наличие tariff_id
if settings.is_tariffs_mode():
if subscription.tariff_id:
# Проверяем, суточный ли тариф — у суточных нет period_prices, продление через resume
from app.database.crud.tariff import get_tariff_by_id
# Триальная подписка с тарифом — направляем на покупку этого тарифа
if subscription.is_trial:
if subscription.tariff_id and settings.is_tariffs_mode():
from .tariff_purchase import show_tariff_extend
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and getattr(tariff, 'is_daily', False):
# Суточный тариф: перенаправляем на страницу подписки (там кнопка «Возобновить»)
await show_subscription_info(callback, db_user, db)
return
await show_tariff_extend(callback, db_user, db)
return
# Триал без тарифа предлагаем выбрать
await callback.message.edit_text(
'🎯 <b>Пробный период заканчивается</b>\n\nЧтобы продолжить пользоваться VPN, выберите подходящий тариф.',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data='menu_buy')],
[
types.InlineKeyboardButton(
text=texts.t('WEBHOOK_CLOSE_BUTTON', '✖️ Закрыть'),
callback_data='webhook:close',
)
],
]
),
parse_mode='HTML',
)
await callback.answer()
return
# Подписка с тарифом — всегда используем тарифный flow,
# даже если бот в классическом режиме (подписка могла быть куплена через кабинет)
if subscription.tariff_id:
# Проверяем, суточный ли тариф — у суточных нет period_prices, продление через resume
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and getattr(tariff, 'is_daily', False):
# Суточный тариф: перенаправляем на страницу подписки (там кнопка «Возобновить»)
await show_subscription_info(callback, db_user, db)
return
if tariff:
# У подписки есть тариф - перенаправляем на продление по тарифу
from .tariff_purchase import show_tariff_extend
await show_tariff_extend(callback, db_user, db)
return
# У подписки нет тарифа - предлагаем выбрать тариф
if settings.is_tariffs_mode():
# У подписки нет тарифа, но режим тарифов включён - предлагаем выбрать тариф
await callback.message.edit_text(
'📦 <b>Выберите тариф для продления</b>\n\n'
'Ваша текущая подписка была создана до введения тарифов.\n'
@@ -1706,6 +1735,10 @@ async def handle_extend_subscription(
# original = price before ALL discounts, final = price with all discounts
total_original_price = pricing.original_total
# Пропускаем периоды с нулевой ценой — защита от бесплатного продления
if pricing.final_total <= 0 and pricing.base_price <= 0:
continue
renewal_prices[days] = {
'final': pricing.final_total,
'original': total_original_price,
@@ -1899,7 +1932,7 @@ async def confirm_extend_subscription(
await callback.answer('⚠ Ошибка расчета стоимости', show_alert=True)
return
if db_user.balance_kopeks < price:
if price > 0 and db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
required_text = texts.format_price(price)
message_text = texts.t(
@@ -2307,7 +2340,7 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
)
logger.info('ИТОГО: ₽', final_price=final_price / 100)
if db_user.balance_kopeks < final_price:
if final_price > 0 and db_user.balance_kopeks < final_price:
missing_kopeks = final_price - db_user.balance_kopeks
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
@@ -4415,8 +4448,8 @@ async def _extend_existing_subscription(
device_limit=device_limit,
)
# Проверяем баланс пользователя
if db_user.balance_kopeks < price_kopeks:
# Проверяем баланс пользователя (при 100% скидке — пропускаем)
if price_kopeks > 0 and db_user.balance_kopeks < price_kopeks:
missing_kopeks = price_kopeks - db_user.balance_kopeks
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
+21 -17
View File
@@ -576,7 +576,7 @@ async def show_tariffs_list(
from app.database.crud.subscription import get_active_subscriptions_by_user_id
active_subs = await get_active_subscriptions_by_user_id(db, db_user.id)
purchased_tariff_ids = {s.tariff_id for s in active_subs if s.tariff_id and s.status in ('active', 'trial')}
purchased_tariff_ids = {s.tariff_id for s in active_subs if s.tariff_id and not s.is_trial}
# Проверяем есть ли у пользователя скидки по периодам
promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None
@@ -619,7 +619,7 @@ async def select_tariff(
from app.database.crud.subscription import get_active_subscriptions_by_user_id
_active = await get_active_subscriptions_by_user_id(db, db_user.id)
_existing = next((s for s in _active if s.tariff_id == tariff_id and s.status in ('active', 'trial')), None)
_existing = next((s for s in _active if s.tariff_id == tariff_id and not s.is_trial), None)
if _existing:
days_left = max(0, (_existing.end_date - datetime.now(UTC)).days) if _existing.end_date else 0
await callback.answer(
@@ -933,9 +933,9 @@ async def handle_custom_confirm(
await callback.answer('Выбранный период недоступен для этого тарифа', show_alert=True)
return
# Проверяем баланс (user already locked, balance is fresh)
# Проверяем баланс (при 100% скидке — пропускаем)
user_balance = db_user.balance_kopeks or 0
if user_balance < total_price:
if total_price > 0 and user_balance < total_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
@@ -1353,7 +1353,7 @@ async def confirm_tariff_purchase(
# Проверяем баланс (user already locked, balance is fresh)
user_balance = db_user.balance_kopeks or 0
if user_balance < final_price:
if final_price > 0 and user_balance < final_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
@@ -1694,7 +1694,7 @@ async def confirm_daily_tariff_purchase(
# Проверяем баланс (user already locked, balance is fresh)
user_balance = db_user.balance_kopeks or 0
if user_balance < final_daily_price:
if final_daily_price > 0 and user_balance < final_daily_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
@@ -2013,8 +2013,6 @@ async def show_tariff_extend(
# Show subscription picker for extending
keyboard = []
for sub in sorted(active_subs, key=lambda s: s.id):
if sub.is_trial:
continue
tariff_name = ''
if sub.tariff_id:
_t = await get_tariff_by_id(db, sub.tariff_id)
@@ -2246,7 +2244,7 @@ async def confirm_tariff_extend(
# Проверяем баланс
user_balance = db_user.balance_kopeks or 0
if user_balance < final_price:
if final_price > 0 and user_balance < final_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
@@ -2266,11 +2264,17 @@ async def confirm_tariff_extend(
await callback.answer('Ошибка списания баланса', show_alert=True)
return
# Продлеваем подписку (параметры тарифа не меняются, только добавляется время)
# Запоминаем, был ли триал ДО продления
was_trial = subscription.is_trial
# Продлеваем подписку; для триала передаём tariff_id чтобы сбросить is_trial
subscription = await extend_subscription(
db,
subscription,
days=period,
tariff_id=tariff.id if was_trial else None,
traffic_limit_gb=tariff.traffic_limit_gb if was_trial else None,
device_limit=actual_device_limit if was_trial else None,
)
# Обновляем пользователя в Remnawave
@@ -2279,8 +2283,8 @@ async def confirm_tariff_extend(
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason='продление тарифа',
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT or was_trial,
reset_reason='конвертация триала' if was_trial else 'продление тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave', error=e)
@@ -2303,7 +2307,7 @@ async def confirm_tariff_extend(
subscription,
None, # Транзакция отсутствует, оплата с баланса
period,
was_trial_conversion=False,
was_trial_conversion=was_trial,
amount_kopeks=final_price,
purchase_type='renewal',
)
@@ -2836,7 +2840,7 @@ async def confirm_tariff_switch(
# Проверяем баланс
user_balance = db_user.balance_kopeks or 0
if user_balance < final_price:
if final_price > 0 and user_balance < final_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
@@ -3042,7 +3046,7 @@ async def confirm_daily_tariff_switch(
# Проверяем баланс (user already locked, balance is fresh)
user_balance = db_user.balance_kopeks or 0
if user_balance < final_daily_price:
if final_daily_price > 0 and user_balance < final_daily_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
@@ -3946,8 +3950,8 @@ async def return_to_saved_tariff_cart(
user_balance = db_user.balance_kopeks or 0
traffic = format_traffic(tariff.traffic_limit_gb)
# Проверяем баланс
if user_balance < total_price:
# Проверяем баланс (при 100% скидке — пропускаем)
if total_price > 0 and user_balance < total_price:
missing = total_price - user_balance
if cart_mode == 'daily_tariff_purchase':
+3 -3
View File
@@ -332,7 +332,7 @@ async def confirm_reset_traffic(
reset_price = _calculate_traffic_reset_price(subscription)
if db_user.balance_kopeks < reset_price:
if reset_price > 0 and db_user.balance_kopeks < reset_price:
missing_kopeks = reset_price - db_user.balance_kopeks
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
@@ -574,7 +574,7 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
total_discount_value = int(discount_per_month * charged_days / 30)
if db_user.balance_kopeks < price:
if price > 0 and db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
# Save cart for auto-purchase after balance top-up
@@ -830,7 +830,7 @@ async def confirm_switch_traffic(
total_price_difference = int(price_difference_per_month * days_remaining / 30)
total_price_difference = max(100, total_price_difference)
if db_user.balance_kopeks < total_price_difference:
if total_price_difference > 0 and db_user.balance_kopeks < total_price_difference:
missing_kopeks = total_price_difference - db_user.balance_kopeks
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
+13
View File
@@ -1755,10 +1755,23 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_kassa_ai_sberpay_enabled():
sberpay_name = settings.get_kassa_ai_sberpay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_KASSA_AI_SBERPAY', f'💳 {sberpay_name}'),
callback_data=_build_callback('kassa_ai_sberpay'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_kassa_ai_enabled()
and not settings.is_kassa_ai_sbp_enabled()
and not settings.is_kassa_ai_card_enabled()
and not settings.is_kassa_ai_sberpay_enabled()
):
kassa_ai_name = settings.get_kassa_ai_display_name()
keyboard.append(
+1
View File
@@ -1755,5 +1755,6 @@
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Not connected yet</b>\n\nYour subscription{tariff_label} is active but no VPN connection has been made. Connect to start using the service.",
"WEBHOOK_DEVICE_ADDED": "📱 <b>New device</b>\n\nA new device has been added to your subscription{tariff_label}: <code>{device}</code>",
"WEBHOOK_DEVICE_DELETED": "📱 <b>Device removed</b>\n\nA device has been removed from your subscription{tariff_label}: <code>{device}</code>",
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Torrent detected</b>\n\nTorrent traffic was detected on your connection{tariff_label}. Using torrents may result in subscription restrictions.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Close"
}
+1
View File
@@ -1776,5 +1776,6 @@
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>هنوز متصل نشده‌اید</b>\n\nاشتراک{tariff_label} شما فعال است اما هنوز اتصال VPN برقرار نشده. برای شروع استفاده متصل شوید.",
"WEBHOOK_DEVICE_ADDED": "📱 <b>دستگاه جدید</b>\n\nدستگاه جدیدی به اشتراک{tariff_label} شما اضافه شد: <code>{device}</code>",
"WEBHOOK_DEVICE_DELETED": "📱 <b>دستگاه حذف شد</b>\n\nدستگاهی از اشتراک{tariff_label} شما حذف شد: <code>{device}</code>",
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>تورنت شناسایی شد</b>\n\nترافیک تورنت در اتصال{tariff_label} شما شناسایی شد. استفاده از تورنت ممکن است منجر به محدودیت اشتراک شود.",
"WEBHOOK_CLOSE_BUTTON": "✖️ بستن"
}
+1
View File
@@ -1779,5 +1779,6 @@
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Вы ещё не подключились</b>\n\nВаша подписка{tariff_label} активна, но VPN-соединение не было установлено. Подключитесь, чтобы начать пользоваться.",
"WEBHOOK_DEVICE_ADDED": "📱 <b>Новое устройство</b>\n\nК подписке{tariff_label} подключено новое устройство: <code>{device}</code>",
"WEBHOOK_DEVICE_DELETED": "📱 <b>Устройство удалено</b>\n\nУстройство отключено от подписки{tariff_label}: <code>{device}</code>",
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Обнаружен торрент</b>\n\nВ вашем подключении{tariff_label} обнаружен торрент-трафик. Использование торрентов может привести к ограничению подписки.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть"
}
+1
View File
@@ -1647,5 +1647,6 @@
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Ви ще не підключились</b>\n\nВаша підписка{tariff_label} активна, але VPN-з'єднання не було встановлено. Підключіться, щоб почати користуватися.",
"WEBHOOK_DEVICE_ADDED": "📱 <b>Новий пристрій</b>\n\nДо підписки{tariff_label} підключено новий пристрій: <code>{device}</code>",
"WEBHOOK_DEVICE_DELETED": "📱 <b>Пристрій видалено</b>\n\nПристрій відключено від підписки{tariff_label}: <code>{device}</code>",
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Виявлено торент</b>\n\nУ вашому підключенні{tariff_label} виявлено торент-трафік. Використання торентів може призвести до обмеження підписки.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрити"
}
+1
View File
@@ -1643,6 +1643,7 @@
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>尚未连接</b>\n\n您的订阅{tariff_label}已激活,但尚未建立VPN连接。请连接以开始使用服务。",
"WEBHOOK_DEVICE_ADDED": "📱 <b>新设备</b>\n\n订阅{tariff_label}已添加新设备:<code>{device}</code>",
"WEBHOOK_DEVICE_DELETED": "📱 <b>设备已移除</b>\n\n设备已从订阅{tariff_label}中移除:<code>{device}</code>",
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>检测到种子下载</b>\n\n在您的连接{tariff_label}中检测到种子流量。使用种子可能导致订阅受限。",
"WEBHOOK_CLOSE_BUTTON": "✖️ 关闭",
"BALANCE_TOPPED_UP_CART_SUFFICIENT": "✅ 余额已充值 {amount}\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n余额足够完成订购。",
"BALANCE_TOPPED_UP_CART_INSUFFICIENT": "✅ 余额已充值 {amount}\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n还差:{missing}"
+28 -23
View File
@@ -370,6 +370,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
# Per-channel settings: check if any unsubscribed channel requires deactivation
unsubscribed = [ch for ch in channels if not ch.get('is_subscribed', False)]
deactivated_subs = []
for subscription in active_subs:
should_disable = any(
channel_subscription_service.should_disable_subscription(ch, subscription.is_trial)
@@ -379,6 +380,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
continue
await deactivate_subscription(db, subscription)
deactivated_subs.append(subscription)
sub_type = 'trial' if subscription.is_trial else 'paid'
logger.info(
'Subscription deactivated after channel unsubscribe',
@@ -387,7 +389,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
)
service = SubscriptionService()
for subscription in active_subs:
for subscription in deactivated_subs:
panel_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
@@ -404,29 +406,30 @@ class ChannelCheckerMiddleware(BaseMiddleware):
)
# Notify user about deactivation
try:
normalized = _normalize_channels(channels)
texts = get_texts(user.language or DEFAULT_LANGUAGE)
if settings.is_multi_tariff_enabled() and len(active_subs) > 1:
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE_MULTI',
'🚫 Ваши подписки приостановлены, так как вы отписались от обязательного канала.\n\n'
'Подпишитесь на все каналы для восстановления доступа к VPN.',
if deactivated_subs:
try:
normalized = _normalize_channels(channels)
texts = get_texts(user.language or DEFAULT_LANGUAGE)
if settings.is_multi_tariff_enabled() and len(deactivated_subs) > 1:
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE_MULTI',
'🚫 Ваши подписки приостановлены, так как вы отписались от обязательного канала.\n\n'
'Подпишитесь на все каналы для восстановления доступа к VPN.',
)
else:
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
'🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n'
'Подпишитесь на канал снова, чтобы восстановить доступ к VPN.',
)
channel_kb = get_channel_sub_keyboard(normalized, language=user.language)
await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb)
except Exception as notify_error:
logger.error(
'Failed to send deactivation notification to user',
telegram_id=telegram_id,
notify_error=notify_error,
)
else:
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
'🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n'
'Подпишитесь на канал снова, чтобы восстановить доступ к VPN.',
)
channel_kb = get_channel_sub_keyboard(normalized, language=user.language)
await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb)
except Exception as notify_error:
logger.error(
'Failed to send deactivation notification to user',
telegram_id=telegram_id,
notify_error=notify_error,
)
await db.commit()
except Exception as db_error:
logger.error(
@@ -463,6 +466,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
for subscription in disabled_subs:
await reactivate_subscription(db, subscription)
# Штамп для защиты от echo-webhook user.disabled
subscription.last_webhook_update_at = datetime.now(UTC)
sub_type = 'trial' if subscription.is_trial else 'paid'
logger.info(
'Subscription reactivated after channel subscribe',
+2 -2
View File
@@ -141,8 +141,8 @@ class DailySubscriptionService:
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
)
# Проверяем баланс
if user.balance_kopeks < daily_price:
# Проверяем баланс (при 100% скидке — пропускаем)
if daily_price > 0 and user.balance_kopeks < daily_price:
# Недостаточно средств - приостанавливаем подписку
await suspend_daily_subscription_insufficient_balance(db, subscription)
+1
View File
@@ -18,6 +18,7 @@ logger = structlog.get_logger(__name__)
KASSA_AI_SUB_METHODS = {
'kassa_ai_sbp': {'payment_system_id': 44},
'kassa_ai_card': {'payment_system_id': 36},
'kassa_ai_sberpay': {'payment_system_id': 43},
}
# Кэш для публичного IP
+86 -48
View File
@@ -49,7 +49,6 @@ from app.database.models import (
from app.external.remnawave_api import (
RemnaWaveAPIError,
RemnaWaveUser,
TrafficLimitStrategy,
UserStatus as RemnaWaveUserStatus,
)
from app.localization.texts import get_texts
@@ -58,7 +57,7 @@ from app.services.notification_delivery_service import (
)
from app.services.notification_settings_service import NotificationSettingsService
from app.services.promo_offer_service import promo_offer_service
from app.services.subscription_service import SubscriptionService
from app.services.subscription_service import SubscriptionService, get_traffic_reset_strategy
from app.utils.cache import cache
from app.utils.message_patch import caption_exceeds_telegram_limit
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
@@ -90,6 +89,8 @@ class MonitoringService:
self._notified_users: set[str] = set()
self._last_cleanup = datetime.now(UTC)
self._sla_task = None
# In-memory fallback для cooldown автоплатежей (на случай недоступности Redis)
self._autopay_fail_notified_at: dict[int, datetime] = {}
async def _send_message_with_logo(
self,
@@ -276,8 +277,76 @@ class MonitoringService:
if (current_time - self._last_cleanup).total_seconds() >= 3600:
old_count = len(self._notified_users)
self._notified_users.clear()
# Чистим просроченные записи cooldown автоплатежей
cutoff = current_time - timedelta(seconds=AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS)
expired_ids = [uid for uid, ts in self._autopay_fail_notified_at.items() if ts < cutoff]
for uid in expired_ids:
del self._autopay_fail_notified_at[uid]
self._last_cleanup = current_time
logger.info('🧹 Очищен кеш уведомлений ( записей)', old_count=old_count)
logger.info(
'🧹 Очищен кеш уведомлений',
old_count=old_count,
autopay_cooldown_evicted=len(expired_ids),
autopay_cooldown_remaining=len(self._autopay_fail_notified_at),
)
async def _check_autopay_fail_cooldown(self, user_id: int, user_identifier: str) -> bool:
"""Проверяет, можно ли отправить уведомление об ошибке автоплатежа.
Использует Redis как primary хранилище cooldown, с in-memory fallback.
Returns True если уведомление можно отправить.
"""
# 1. In-memory fallback (работает даже без Redis)
last_notified = self._autopay_fail_notified_at.get(user_id)
if last_notified:
elapsed = (datetime.now(UTC) - last_notified).total_seconds()
if elapsed < AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS:
logger.debug(
'Пропуск уведомления об ошибке автоплатежа — in-memory cooldown активен',
user_identifier=user_identifier,
elapsed_seconds=int(elapsed),
)
return False
# 2. Redis check (если доступен)
cooldown_key = f'autopay_insufficient_balance_notified:{user_id}'
try:
if await cache.exists(cooldown_key):
logger.debug(
'Пропуск уведомления об ошибке автоплатежа — Redis cooldown активен',
user_identifier=user_identifier,
)
return False
except Exception as redis_err:
logger.warning(
'Ошибка проверки cooldown в Redis, используем in-memory fallback',
user_identifier=user_identifier,
redis_err=redis_err,
)
return True
async def _set_autopay_fail_cooldown(self, user_id: int, user_identifier: str) -> None:
"""Устанавливает cooldown после отправки уведомления об ошибке автоплатежа."""
# In-memory (всегда)
self._autopay_fail_notified_at[user_id] = datetime.now(UTC)
# Redis (если доступен)
cooldown_key = f'autopay_insufficient_balance_notified:{user_id}'
try:
await cache.set(
cooldown_key,
1,
expire=AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS,
)
except Exception as redis_err:
logger.warning(
'Не удалось установить cooldown в Redis, in-memory fallback активен',
user_identifier=user_identifier,
redis_err=redis_err,
)
async def _check_expired_subscriptions(self, db: AsyncSession):
try:
@@ -394,7 +463,7 @@ class MonitoringService:
if is_active
else max(subscription.end_date, current_time + timedelta(minutes=1)),
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
description=settings.format_remnawave_user_description(
full_name=user.full_name, username=user.username, telegram_id=user.telegram_id
),
@@ -1264,42 +1333,24 @@ class MonitoringService:
)
else:
failed_count += 1
if user.telegram_id and self.bot:
await self._send_autopay_failed_notification(
user, user.balance_kopeks, charge_amount, subscription=subscription
)
elif not user.telegram_id:
await notification_delivery_service.notify_autopay_failed(
user=user,
reason='Ошибка списания средств',
)
if await self._check_autopay_fail_cooldown(user.id, user_identifier):
if user.telegram_id and self.bot:
await self._send_autopay_failed_notification(
user, user.balance_kopeks, charge_amount, subscription=subscription
)
elif not user.telegram_id:
await notification_delivery_service.notify_autopay_failed(
user=user,
reason='Ошибка списания средств',
)
await self._set_autopay_fail_cooldown(user.id, user_identifier)
logger.warning(
'💳 Ошибка списания средств для автопродления пользователя', user_identifier=user_identifier
)
else:
failed_count += 1
# Проверяем кулдаун уведомления через Redis, чтобы не спамить
# при каждом срабатывании мониторинга
cooldown_key = f'autopay_insufficient_balance_notified:{user.id}'
should_notify = True
try:
if await cache.exists(cooldown_key):
should_notify = False
logger.debug(
'💳 Пропуск уведомления о недостаточном балансе для пользователя — кулдаун активен',
user_identifier=user_identifier,
)
except Exception as redis_err:
# Fallback: если Redis недоступен — отправляем уведомление
logger.warning(
'⚠️ Ошибка проверки кулдауна в Redis для пользователя : . Отправляем уведомление.',
user_identifier=user_identifier,
redis_err=redis_err,
)
if should_notify:
if await self._check_autopay_fail_cooldown(user.id, user_identifier):
if user.telegram_id and self.bot:
await self._send_autopay_failed_notification(
user, user.balance_kopeks, charge_amount, subscription=subscription
@@ -1309,20 +1360,7 @@ class MonitoringService:
user=user,
reason='Недостаточно средств на балансе',
)
# Ставим ключ кулдауна после отправки
try:
await cache.set(
cooldown_key,
1,
expire=AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS,
)
except Exception as redis_err:
logger.warning(
'⚠️ Не удалось установить кулдаун в Redis для пользователя',
user_identifier=user_identifier,
redis_err=redis_err,
)
await self._set_autopay_fail_cooldown(user.id, user_identifier)
logger.warning(
'💳 Недостаточно средств для автопродления у пользователя', user_identifier=user_identifier
+4 -1
View File
@@ -92,11 +92,14 @@ class KassaAiPaymentMixin:
try:
# Используем API для создания заказа
# KassaAI требует email в формате {telegram_id}@telegram.org
target_email = email or (f'{user.telegram_id}@telegram.org' if user and user.telegram_id else None)
result = await kassa_ai_service.create_order(
order_id=order_id,
amount=amount_rubles,
currency=currency,
email=email,
email=target_email,
payment_system_id=payment_system_id
if payment_system_id is not None
else settings.KASSA_AI_PAYMENT_SYSTEM_ID,
+7 -1
View File
@@ -73,6 +73,7 @@ class SeverPayPaymentMixin:
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
user = None
tg_id = 'guest'
# Генерируем уникальный order_id с telegram_id для удобного поиска
@@ -94,12 +95,17 @@ class SeverPayPaymentMixin:
}
try:
# SeverPay требует обязательный client_email
target_email = email or (
f'{user.telegram_id}@telegram.org' if user and user.telegram_id else f'{tg_id}@telegram.org'
)
# Используем API для создания платежа
result = await severpay_service.create_payment(
order_id=order_id,
amount=amount_rubles,
currency=currency,
client_email=email or '',
client_email=target_email,
client_id=str(tg_id),
url_return=return_url or settings.SEVERPAY_RETURN_URL,
lifetime=lifetime,
@@ -129,6 +129,7 @@ def _get_method_defaults() -> dict:
'available_sub_options': [
{'id': 'sbp', 'name': 'СБП'},
{'id': 'card', 'name': 'Карта'},
{'id': 'sberpay', 'name': 'SberPay'},
],
},
'riopay': {
+1 -1
View File
@@ -703,7 +703,7 @@ class PaymentService(
return None
# --- KassaAI ----------------------------------------------------------
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card'):
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card', 'kassa_ai_sberpay'):
if not settings.is_kassa_ai_enabled():
logger.warning('KassaAI is not enabled, cannot create guest payment')
return None
+3 -3
View File
@@ -31,9 +31,9 @@ from app.database.models import (
from app.external.remnawave_api import (
RemnaWaveAPI,
RemnaWaveAPIError,
TrafficLimitStrategy,
UserStatus,
)
from app.services.subscription_service import get_traffic_reset_strategy
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
@@ -2240,7 +2240,7 @@ class RemnaWaveService:
traffic_limit_bytes=sub.traffic_limit_gb * (1024**3)
if sub.traffic_limit_gb > 0
else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(sub.tariff),
telegram_id=user.telegram_id,
email=user.email,
description=settings.format_remnawave_user_description(
@@ -2325,7 +2325,7 @@ class RemnaWaveService:
status=status,
expire_at=expire_at,
traffic_limit_bytes=create_kwargs['traffic_limit_bytes'],
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(sub.tariff),
email=user.email,
description=create_kwargs['description'],
active_internal_squads=sub.connected_squads,
+194 -75
View File
@@ -16,7 +16,7 @@ from typing import Any
import structlog
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import delete
from sqlalchemy import delete, inspect as sa_inspect
from sqlalchemy.exc import PendingRollbackError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.exc import StaleDataError
@@ -27,6 +27,7 @@ from app.database.crud.subscription import (
decrement_subscription_server_counts,
expire_subscription,
get_subscription_by_user_id,
is_recently_updated_by_webhook,
reactivate_subscription,
update_subscription_usage,
)
@@ -79,6 +80,7 @@ _TEXT_KEY_TO_SETTING: dict[str, str] = {
'WEBHOOK_USER_NOT_CONNECTED': 'WEBHOOK_NOTIFY_NOT_CONNECTED',
'WEBHOOK_DEVICE_ADDED': 'WEBHOOK_NOTIFY_DEVICES',
'WEBHOOK_DEVICE_DELETED': 'WEBHOOK_NOTIFY_DEVICES',
'WEBHOOK_TORRENT_DETECTED': 'WEBHOOK_NOTIFY_TORRENT_DETECTED',
}
# Admin event display names for notification messages
@@ -124,11 +126,14 @@ _ADMIN_NODE_CONNECTION_EVENTS = frozenset({'node.connection_lost', 'node.connect
class RemnaWaveWebhookService:
"""Processes incoming webhooks from RemnaWave backend."""
# In-memory guard: tracks recent panel recreations per subscription_id.
# Prevents unbounded user.deleted → recreate → user.deleted loops.
# Key: subscription_id, Value: datetime of last recreation attempt.
# NOTE: In-memory guards. Only correct with a single-worker deployment.
# For multi-worker setups, move to Redis or another shared store.
_recent_recreations: dict[int, datetime] = {}
_RECREATION_GUARD_SECONDS: int = 120 # 2-minute cooldown
_intentional_panel_deletions_by_uuid: dict[str, datetime] = {}
_intentional_panel_deletions_by_telegram_id: dict[int, datetime] = {}
_INTENTIONAL_PANEL_DELETION_GUARD_SECONDS: int = 300
_MAX_INTENTIONAL_ENTRIES: int = 10_000
def __init__(self, bot: Bot) -> None:
self.bot = bot
@@ -154,6 +159,7 @@ class RemnaWaveWebhookService:
'user.not_connected': self._handle_user_not_connected,
'user_hwid_devices.added': self._handle_device_added,
'user_hwid_devices.deleted': self._handle_device_deleted,
'torrent_blocker.report': self._handle_torrent_detected,
}
# Admin-scoped handlers: no user resolution, notify admin chat
@@ -169,18 +175,111 @@ class RemnaWaveWebhookService:
"""Check if the event is admin-scoped (no DB session needed)."""
return event_name in self._admin_handlers
def needs_db_session(self, event_name: str) -> bool:
"""Check if the event requires a DB session (user handler or dual event)."""
return event_name in self._user_handlers
@classmethod
def _prune_intentional_panel_deletions(cls) -> None:
if not cls._intentional_panel_deletions_by_uuid and not cls._intentional_panel_deletions_by_telegram_id:
return
now = datetime.now(UTC)
uuid_keys = [
key
for key, created_at in cls._intentional_panel_deletions_by_uuid.items()
if (now - created_at).total_seconds() >= cls._INTENTIONAL_PANEL_DELETION_GUARD_SECONDS
]
for key in uuid_keys:
del cls._intentional_panel_deletions_by_uuid[key]
telegram_keys = [
key
for key, created_at in cls._intentional_panel_deletions_by_telegram_id.items()
if (now - created_at).total_seconds() >= cls._INTENTIONAL_PANEL_DELETION_GUARD_SECONDS
]
for key in telegram_keys:
del cls._intentional_panel_deletions_by_telegram_id[key]
@classmethod
def mark_intentional_panel_deletion(
cls,
*,
panel_uuids: list[str] | None = None,
telegram_id: int | None = None,
) -> None:
cls._prune_intentional_panel_deletions()
total = len(cls._intentional_panel_deletions_by_uuid) + len(cls._intentional_panel_deletions_by_telegram_id)
if total >= cls._MAX_INTENTIONAL_ENTRIES:
logger.warning('Intentional deletion guard at capacity, skipping', total=total)
return
now = datetime.now(UTC)
for panel_uuid in panel_uuids or []:
normalized = (panel_uuid or '').strip()
if normalized:
cls._intentional_panel_deletions_by_uuid[normalized] = now
if telegram_id is not None:
cls._intentional_panel_deletions_by_telegram_id[int(telegram_id)] = now
@classmethod
def _is_intentional_panel_deletion_event(cls, data: dict[str, Any]) -> bool:
cls._prune_intentional_panel_deletions()
candidate_uuids: list[str] = []
candidate_telegram_ids: list[int] = []
for value in (data.get('uuid'), data.get('userUuid')):
if value:
candidate_uuids.append(str(value).strip())
telegram_id = data.get('telegramId')
if telegram_id:
try:
candidate_telegram_ids.append(int(telegram_id))
except (TypeError, ValueError):
pass
nested_user = data.get('user')
if isinstance(nested_user, dict):
nested_uuid = nested_user.get('uuid')
if nested_uuid:
candidate_uuids.append(str(nested_uuid).strip())
nested_tid = nested_user.get('telegramId')
if nested_tid:
try:
candidate_telegram_ids.append(int(nested_tid))
except (TypeError, ValueError):
pass
return any(uid in cls._intentional_panel_deletions_by_uuid for uid in candidate_uuids) or any(
tid in cls._intentional_panel_deletions_by_telegram_id for tid in candidate_telegram_ids
)
async def process_event(self, db: AsyncSession | None, event_name: str, data: dict) -> bool:
"""Route event to the appropriate handler.
Returns True if the event was processed, False if skipped/unknown.
db may be None for admin events that don't require database access.
"""
# Check if event has both admin and user handlers (e.g. torrent_blocker.report)
user_handler = self._user_handlers.get(event_name)
if event_name in self._admin_handlers and user_handler:
# Dual event: send admin notification AND process user handler
await self._process_admin_event(event_name, data)
if db is not None:
await self._process_user_event(db, event_name, data, user_handler)
return True
# Check admin-scoped handlers (no DB needed)
if event_name in self._admin_handlers:
return await self._process_admin_event(event_name, data)
# Check user-scoped handlers (require DB session)
user_handler = self._user_handlers.get(event_name)
if user_handler:
if db is None:
logger.error('RemnaWave webhook: DB session required for user event', event_name=event_name)
@@ -583,8 +682,12 @@ class RemnaWaveWebhookService:
format_kwargs = {}
if 'tariff_label' not in format_kwargs:
tariff_label = ''
if settings.is_multi_tariff_enabled() and subscription and getattr(subscription, 'tariff', None):
tariff_label = f' «{subscription.tariff.name}»'
if settings.is_multi_tariff_enabled() and subscription:
# Access tariff only if already eagerly loaded to avoid
# MissingGreenlet from lazy loading in async context
loaded_tariff = sa_inspect(subscription).dict.get('tariff')
if loaded_tariff is not None:
tariff_label = f' «{loaded_tariff.name}»'
format_kwargs['tariff_label'] = tariff_label
if format_kwargs:
@@ -647,7 +750,7 @@ class RemnaWaveWebhookService:
# Суточные подписки управляются DailySubscriptionService.
# Remnawave может прислать user.expired если sync не дошёл (старый end_date),
# но локально подписка ещё жива — не экспайрим её.
tariff = getattr(subscription, 'tariff', None)
tariff = sa_inspect(subscription).dict.get('tariff')
is_active_daily = (
tariff is not None
and getattr(tariff, 'is_daily', False)
@@ -686,7 +789,7 @@ class RemnaWaveWebhookService:
return
# Суточные подписки управляются DailySubscriptionService — не деактивируем
tariff = getattr(subscription, 'tariff', None)
tariff = sa_inspect(subscription).dict.get('tariff')
is_active_daily = (
tariff is not None
and getattr(tariff, 'is_daily', False)
@@ -702,6 +805,18 @@ class RemnaWaveWebhookService:
await db.commit()
return
# Защита от echo-webhook: если подписка была недавно реактивирована
# (канал-реподписка ставит last_webhook_update_at), пропускаем
if subscription.status == SubscriptionStatus.ACTIVE.value and is_recently_updated_by_webhook(subscription):
logger.info(
'Webhook user.disabled: подписка недавно реактивирована, пропуск echo-webhook',
subscription_id=subscription.id,
user_id=user.id,
)
self._stamp_webhook_update(subscription)
await db.commit()
return
self._stamp_webhook_update(subscription)
if subscription.status != SubscriptionStatus.DISABLED.value:
await deactivate_subscription(db, subscription)
@@ -944,59 +1059,32 @@ class RemnaWaveWebhookService:
logger.error('Webhook: user not found after rollback', user_id=user_id)
return
# Check if subscription has a future end_date — likely a spurious user.deleted
# (e.g., RemnaWave sends user.deleted during panel resync when modifying another user)
subscription_still_valid = (
subscription is not None and subscription.end_date is not None and subscription.end_date > datetime.now(UTC)
)
# user.deleted = user removed from panel. Deactivate everything.
# No recreation attempts — if it was a mistake, admin can re-sync.
if subscription:
if subscription_still_valid:
# Subscription is still valid — don't mark as expired.
# Clear only panel linkage fields (URLs, UUID) but keep status and squads
# so that re-creation can restore VPN access.
logger.warning(
'Webhook user.deleted: subscription has future end_date, '
'keeping active status and attempting panel re-creation',
if subscription.status != SubscriptionStatus.EXPIRED.value:
subscription.status = SubscriptionStatus.EXPIRED.value
logger.info(
'Webhook user.deleted: subscription expired',
sub_id=sub_id,
user_id=user_id,
end_date=subscription.end_date,
status=subscription.status,
)
subscription.subscription_url = None
subscription.subscription_crypto_link = None
subscription.remnawave_short_uuid = None
# Keep connected_squads — needed for panel re-creation
subscription.updated_at = datetime.now(UTC)
else:
# Subscription expired or has no end_date — safe to mark as expired
if subscription.status != SubscriptionStatus.EXPIRED.value:
subscription.status = SubscriptionStatus.EXPIRED.value
logger.info(
'Webhook: subscription marked expired (user deleted in panel) for user',
sub_id=sub_id,
user_id=user_id,
)
subscription.subscription_url = None
subscription.subscription_crypto_link = None
subscription.remnawave_short_uuid = None
subscription.connected_squads = []
subscription.updated_at = datetime.now(UTC)
subscription.subscription_url = None
subscription.subscription_crypto_link = None
subscription.remnawave_short_uuid = None
subscription.connected_squads = []
subscription.updated_at = datetime.now(UTC)
# In multi-tariff mode clear per-subscription UUID here
if settings.is_multi_tariff_enabled():
subscription.remnawave_uuid = None
# Remove SubscriptionServer link rows (panel user no longer exists)
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id))
# Clear remnawave linkage — only in single-tariff mode (multi-tariff uses per-subscription UUIDs)
# Clear remnawave linkage
if not settings.is_multi_tariff_enabled():
if user.remnawave_uuid:
user.remnawave_uuid = None
# In multi-tariff mode, subscription.remnawave_uuid was cleared above.
# If subscription was None (fallback path), extract panel UUID from data and
# clear it from the matching subscription manually.
elif subscription is None:
panel_uuid = data.get('uuid') or data.get('userUuid')
if panel_uuid:
@@ -1007,35 +1095,55 @@ class RemnaWaveWebhookService:
sub.remnawave_short_uuid = None
break
# Deactivate sibling subscriptions whose panel user also no longer exists.
# In multi-tariff each subscription has its own panel user — only expire those
# that are actually gone (verified via API), leave alive ones untouched.
await db.refresh(user, ['subscriptions'])
now = datetime.now(UTC)
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
for other_sub in getattr(user, 'subscriptions', None) or []:
if other_sub.id == sub_id:
continue
if other_sub.status in (SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value):
continue
# Check if this sibling's panel user still exists
sibling_uuid = getattr(other_sub, 'remnawave_uuid', None) if settings.is_multi_tariff_enabled() else None
if not sibling_uuid and not settings.is_multi_tariff_enabled():
sibling_uuid = getattr(user, 'remnawave_uuid', None)
if sibling_uuid and subscription_service.is_configured:
try:
async with subscription_service.get_api_client() as api:
panel_user = await api.get_user_by_uuid(sibling_uuid)
if panel_user is not None:
continue # still alive in panel, don't touch
except Exception:
pass # API error — deactivate to be safe
other_sub.status = SubscriptionStatus.EXPIRED.value
other_sub.subscription_url = None
other_sub.subscription_crypto_link = None
other_sub.remnawave_short_uuid = None
other_sub.connected_squads = []
other_sub.updated_at = now
if settings.is_multi_tariff_enabled():
other_sub.remnawave_uuid = None
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == other_sub.id))
logger.info(
'Webhook user.deleted: deactivated sibling subscription (panel user gone)',
other_sub_id=other_sub.id,
user_id=user_id,
)
await db.commit()
if subscription_still_valid:
# Attempt to re-create user in panel to restore VPN access.
# If recreation fails, fall back to expiring the subscription
# so it doesn't stay in ACTIVE-but-no-panel limbo.
recreated = await self._attempt_panel_recreation(db, user, subscription)
if not recreated:
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.connected_squads = []
subscription.updated_at = datetime.now(UTC)
await db.commit()
await self._notify_user(
user,
'WEBHOOK_SUB_DELETED',
reply_markup=self._get_renew_keyboard(
user, getattr(subscription, 'id', None) if subscription else None
),
subscription=subscription,
)
else:
await self._notify_user(
user,
'WEBHOOK_SUB_DELETED',
reply_markup=self._get_renew_keyboard(
user, getattr(subscription, 'id', None) if subscription else None
),
subscription=subscription,
)
await self._notify_user(
user,
'WEBHOOK_SUB_DELETED',
reply_markup=self._get_renew_keyboard(user, getattr(subscription, 'id', None) if subscription else None),
subscription=subscription,
)
async def _attempt_panel_recreation(self, db: AsyncSession, user: User, subscription: Subscription) -> bool:
"""Re-create user in RemnaWave panel after spurious user.deleted webhook.
@@ -1296,3 +1404,14 @@ class RemnaWaveWebhookService:
format_kwargs={'device': device_name or ''},
subscription=subscription,
)
async def _handle_torrent_detected(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
logger.info('Webhook: torrent detected for user', user_id=user.id)
await self._notify_user(
user,
'WEBHOOK_TORRENT_DETECTED',
reply_markup=self._get_subscription_keyboard(user),
subscription=subscription,
)
@@ -316,7 +316,7 @@ async def _prepare_auto_extend_context(
)
return None
if price_kopeks <= 0:
if price_kopeks <= 0 and pricing.base_price <= 0:
logger.warning(
'🔁 Автопокупка: некорректная цена продления у пользователя',
price_kopeks=price_kopeks,
@@ -424,7 +424,7 @@ async def _auto_extend_subscription(
if prepared is None:
return False
if user.balance_kopeks < prepared.price_kopeks:
if prepared.price_kopeks > 0 and user.balance_kopeks < prepared.price_kopeks:
logger.info(
'🔁 Автопокупка: у пользователя недостаточно средств для продления (<)',
format_user_id=_format_user_id(user),
@@ -801,7 +801,7 @@ async def _auto_purchase_tariff(
final_price = result.final_total
consume_promo = result.promo_offer_discount > 0
if user.balance_kopeks < final_price:
if final_price > 0 and user.balance_kopeks < final_price:
logger.info(
'🔁 Автопокупка тарифа: у пользователя недостаточно средств (<)',
format_user_id=_format_user_id(user),
@@ -1131,7 +1131,7 @@ async def _auto_purchase_daily_tariff(
final_price, _, _ = PricingEngine.apply_stacked_discounts(daily_price, group_pct, offer_pct)
consume_promo = offer_pct > 0
if user.balance_kopeks < final_price:
if final_price > 0 and user.balance_kopeks < final_price:
logger.info(
'🔁 Автопокупка суточного тарифа: у пользователя недостаточно средств (<)',
format_user_id=_format_user_id(user),
@@ -1532,8 +1532,8 @@ async def _auto_add_devices(
days_left=days_left,
)
# Проверяем баланс (с актуальной ценой)
if user.balance_kopeks < price_kopeks:
# Проверяем баланс (при 100% скидке — пропускаем)
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
logger.info(
'🔁 Автопокупка устройств: у пользователя недостаточно средств (<)',
format_user_id=_format_user_id(user),
@@ -1883,8 +1883,8 @@ async def _auto_add_traffic(
period_hint_days=period_hint_days,
)
# Verify balance (with fresh price)
if user.balance_kopeks < price_kopeks:
# Verify balance (при 100% скидке — пропускаем)
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
logger.info(
'🔁 Автопокупка трафика: у пользователя недостаточно средств (<)',
format_user_id=_format_user_id(user),
@@ -2172,7 +2172,7 @@ async def try_auto_extend_expired_after_topup(
breakdown=pricing.breakdown,
)
if renewal_cost <= 0:
if renewal_cost <= 0 and pricing.base_price <= 0:
logger.warning(
'❌ Автопродление expired: некорректная стоимость',
format_user_id=_format_user_id(user),
@@ -2180,8 +2180,8 @@ async def try_auto_extend_expired_after_topup(
)
return False
# Check balance
if user.balance_kopeks < renewal_cost:
# Check balance (skip for 100% discount)
if renewal_cost > 0 and user.balance_kopeks < renewal_cost:
logger.info(
'🔄 Автопродление expired: недостаточно средств',
format_user_id=_format_user_id(user),
@@ -2523,8 +2523,8 @@ async def try_resume_disabled_daily_after_topup(
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
)
# Check balance (uses locked user's balance_kopeks — safe from concurrent reads)
if user.balance_kopeks < daily_price:
# Check balance (при 100% скидке — пропускаем)
if daily_price > 0 and user.balance_kopeks < daily_price:
logger.info(
'🔄 Авто-возобновление daily: недостаточно средств',
format_user_id=_format_user_id(user),
@@ -3039,7 +3039,7 @@ async def _process_legacy_generic_cart(
pricing = prepared.pricing
selection = prepared.selection
if pricing.final_total <= 0:
if pricing.final_total <= 0 and pricing.base_original_total <= 0:
logger.warning(
'Автопокупка: итоговая сумма для пользователя некорректна',
format_user_id=_format_user_id(user),
@@ -3047,7 +3047,7 @@ async def _process_legacy_generic_cart(
)
return False
if user.balance_kopeks < pricing.final_total:
if pricing.final_total > 0 and user.balance_kopeks < pricing.final_total:
logger.info(
'Автопокупка: у пользователя недостаточно средств',
format_user_id=_format_user_id(user),
@@ -989,10 +989,12 @@ class MiniAppSubscriptionPurchaseService:
user = context.user
texts = get_texts(getattr(user, 'language', None))
if pricing.final_total <= 0:
# Block only if pricing is genuinely invalid (no base price configured).
# final_total == 0 with base_original_total > 0 means a valid 100% discount.
if pricing.final_total <= 0 and pricing.base_original_total <= 0:
raise PurchaseValidationError('Invalid total amount', code='calculation_error')
if user.balance_kopeks < pricing.final_total:
if pricing.final_total > 0 and user.balance_kopeks < pricing.final_total:
raise PurchaseBalanceError(
texts.t(
'MINIAPP_PURCHASE_STATUS_INSUFFICIENT',
+5
View File
@@ -944,6 +944,11 @@ class BotConfigurationService:
'format': 'Булево значение.',
'example': 'true',
},
'WEBHOOK_NOTIFY_TORRENT_DETECTED': {
'description': 'Уведомление пользователю при обнаружении торрент-трафика.',
'format': 'Булево значение.',
'example': 'true',
},
'RESET_TRAFFIC_ON_TARIFF_SWITCH': {
'description': (
'Автоматически сбрасывает счётчик использованного трафика '
+10
View File
@@ -817,6 +817,16 @@ class UserService:
else:
delete_mode = 'delete' if force_panel_delete else settings.get_remnawave_user_delete_mode()
# Помечаем ВСЕ UUID до цикла, чтобы webhook от первого удаления
# не пришёл раньше чем помечены остальные
if delete_mode == 'delete':
from app.services.remnawave_webhook_service import RemnaWaveWebhookService
RemnaWaveWebhookService.mark_intentional_panel_deletion(
panel_uuids=panel_uuids,
telegram_id=int(user.telegram_id) if user.telegram_id else None,
)
for panel_uuid in panel_uuids:
try:
from app.services.remnawave_service import RemnaWaveService
+4 -4
View File
@@ -6563,8 +6563,8 @@ async def purchase_tariff_endpoint(
group_pcts = bd.get('group_discount_pct', {})
discount_percent = group_pcts.get('period', 0)
# Проверяем баланс
if user.balance_kopeks < price_kopeks:
# Проверяем баланс (при 100% скидке — пропускаем)
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
@@ -7194,8 +7194,8 @@ async def purchase_traffic_topup_endpoint(
subscription.end_date,
)
# Проверяем баланс
if user.balance_kopeks < final_price:
# Проверяем баланс (при 100% скидке — пропускаем)
if final_price > 0 and user.balance_kopeks < final_price:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
+15 -9
View File
@@ -97,18 +97,23 @@ def create_remnawave_webhook_router(bot: Bot) -> APIRouter:
status_code=status.HTTP_400_BAD_REQUEST,
)
# Extract and validate event info
scope = payload.get('scope', '')
event = payload.get('event', '')
# Extract and validate event info. Recent RemnaWave payloads send only
# the fully-qualified event name (for example "user.modified") without
# a separate top-level scope field.
event = str(payload.get('event', '') or '').strip()
scope = str(payload.get('scope', '') or '').strip()
data = payload.get('data')
if not scope or not event:
logger.warning('RemnaWave webhook: missing scope or event')
if not event:
logger.warning('RemnaWave webhook: missing event')
return JSONResponse(
{'status': 'error', 'reason': 'missing_scope_or_event'},
{'status': 'error', 'reason': 'missing_event'},
status_code=status.HTTP_400_BAD_REQUEST,
)
if not scope and '.' in event:
scope = event.split('.', 1)[0]
if not isinstance(data, dict):
data = {}
@@ -124,8 +129,9 @@ def create_remnawave_webhook_router(bot: Bot) -> APIRouter:
# Process event — return 200 to prevent retries for application-level errors.
# Only return non-200 for infrastructure failures (DB unavailable).
# Admin events (node/service/crm) don't need a DB session.
if webhook_service.is_admin_event(event_name):
# Admin-only events (node/service/crm) don't need a DB session.
# Dual events (admin + user, e.g. torrent_blocker.report) need DB for user handler.
if webhook_service.is_admin_event(event_name) and not webhook_service.needs_db_session(event_name):
try:
processed = await webhook_service.process_event(None, event_name, data)
return JSONResponse({'status': 'ok', 'processed': processed})
@@ -133,7 +139,7 @@ def create_remnawave_webhook_router(bot: Bot) -> APIRouter:
logger.exception('RemnaWave webhook processing error for event', event_name=event_name)
return JSONResponse({'status': 'ok', 'processed': False})
# User events require a DB session
# User events and dual events require a DB session
try:
async with AsyncSessionLocal() as db:
try:
@@ -0,0 +1,48 @@
"""include limited status in partial unique index for subscriptions
Revision ID: 0053
Revises: 0052
Create Date: 2026-04-03
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0053'
down_revision: Union[str, None] = '0052'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Drop old partial unique index that only covered active/trial
op.execute(sa.text('DROP INDEX IF EXISTS uq_subscriptions_user_tariff_active'))
# Recreate with limited status included — a limited subscription (traffic
# exhausted but time remaining) is still "alive" and should prevent
# duplicate subscriptions for the same user+tariff combination.
op.execute(
sa.text(
"""
CREATE UNIQUE INDEX uq_subscriptions_user_tariff_active
ON subscriptions (user_id, tariff_id)
WHERE tariff_id IS NOT NULL AND status IN ('active', 'trial', 'limited')
"""
)
)
def downgrade() -> None:
op.execute(sa.text('DROP INDEX IF EXISTS uq_subscriptions_user_tariff_active'))
op.execute(
sa.text(
"""
CREATE UNIQUE INDEX uq_subscriptions_user_tariff_active
ON subscriptions (user_id, tariff_id)
WHERE tariff_id IS NOT NULL AND status IN ('active', 'trial')
"""
)
)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.43.0"
version = "3.45.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
@@ -114,7 +114,7 @@ async def test_create_platega_payment_success(monkeypatch: pytest.MonkeyPatch) -
amount_kopeks=50_000,
description='Пополнение счёта',
language='ru',
payment_method_code=10,
payment_method_code=11,
)
assert result is not None
@@ -125,9 +125,9 @@ async def test_create_platega_payment_success(monkeypatch: pytest.MonkeyPatch) -
assert 'correlation_id' in result and len(result['correlation_id']) == 32
assert captured_args['user_id'] == 42
assert captured_args['amount_kopeks'] == 50_000
assert captured_args['payment_method_code'] == 10
assert captured_args['metadata']['selected_method'] == 10
assert stub.calls and stub.calls[0]['payment_method'] == 10
assert captured_args['payment_method_code'] == 11
assert captured_args['metadata']['selected_method'] == 11
assert stub.calls and stub.calls[0]['payment_method'] == 11
assert stub.calls[0]['amount'] == pytest.approx(500.0)
assert stub.calls[0]['currency'] == 'RUB'
assert captured_args['metadata']['language'] == 'ru'
@@ -209,13 +209,13 @@ def test_get_platega_active_methods_parses_and_filters(monkeypatch: pytest.Monke
monkeypatch.setattr(
settings,
'PLATEGA_ACTIVE_METHODS',
' 2,10, 11 ;12,13,13,invalid ',
' 2, 11 ;12,13,13,invalid ',
raising=False,
)
methods = settings.get_platega_active_methods()
assert methods == [2, 10, 11, 12, 13]
assert methods == [2, 11, 12, 13]
def test_get_platega_active_methods_returns_default(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -227,7 +227,7 @@ def test_get_platega_active_methods_returns_default(monkeypatch: pytest.MonkeyPa
def test_platega_method_display_helpers() -> None:
assert settings.get_platega_method_display_name(10) == 'Банковские карты (RUB)'
assert settings.get_platega_method_display_title(10) == '💳 Карты (RUB)'
assert settings.get_platega_method_display_name(11) == 'Карты (RUB)'
assert settings.get_platega_method_display_title(11) == '💳 Карты (RUB)'
assert settings.get_platega_method_display_name(999) == 'Метод 999'
assert settings.get_platega_method_display_title(999) == 'Platega 999'
+139
View File
@@ -0,0 +1,139 @@
import hashlib
import hmac
import json
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock
import pytest
from starlette.requests import Request
from app.config import settings
from app.services.remnawave_webhook_service import RemnaWaveWebhookService
from app.webserver.remnawave_webhook import create_remnawave_webhook_router
@pytest.fixture(autouse=True)
def reset_remnawave_webhook_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, 'REMNAWAVE_WEBHOOK_ENABLED', True, raising=False)
monkeypatch.setattr(settings, 'REMNAWAVE_WEBHOOK_PATH', '/remnawave-webhook', raising=False)
monkeypatch.setattr(
settings,
'REMNAWAVE_WEBHOOK_SECRET',
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
raising=False,
)
RemnaWaveWebhookService._intentional_panel_deletions_by_uuid.clear()
RemnaWaveWebhookService._intentional_panel_deletions_by_telegram_id.clear()
def _get_route(router, path: str, method: str = 'POST'):
for route in router.routes:
if getattr(route, 'path', '') == path and method in getattr(route, 'methods', set()):
return route
raise AssertionError(f'Route {path} with method {method} not found')
def _build_request(path: str, body: bytes, headers: dict[str, str] | None = None) -> Request:
scope = {
'type': 'http',
'asgi': {'version': '3.0'},
'method': 'POST',
'path': path,
'headers': [(k.lower().encode('latin-1'), v.encode('latin-1')) for k, v in (headers or {}).items()],
}
async def receive() -> dict[str, Any]:
return {'type': 'http.request', 'body': body, 'more_body': False}
return Request(scope, receive)
def _signature(body: bytes) -> str:
secret = settings.REMNAWAVE_WEBHOOK_SECRET or ''
return hmac.new(secret.encode('utf-8'), body, hashlib.sha256).hexdigest()
@pytest.mark.anyio('asyncio')
async def test_remnawave_webhook_accepts_event_without_scope(monkeypatch: pytest.MonkeyPatch) -> None:
bot = AsyncMock()
process_event = AsyncMock(return_value=True)
service = SimpleNamespace(
process_event=process_event,
is_admin_event=lambda _event_name: True,
)
monkeypatch.setattr(
'app.webserver.remnawave_webhook.RemnaWaveWebhookService',
lambda _bot: service,
)
payload = {
'event': 'user.modified',
'data': {'uuid': 'user-123'},
'timestamp': '2026-03-30T12:00:00.000Z',
}
raw_body = json.dumps(payload).encode('utf-8')
router = create_remnawave_webhook_router(bot)
path = settings.REMNAWAVE_WEBHOOK_PATH
route = _get_route(router, path)
request = _build_request(
path,
raw_body,
headers={'X-Remnawave-Signature': _signature(raw_body)},
)
response = await route.endpoint(request)
assert response.status_code == 200
process_event.assert_awaited_once_with(None, 'user.modified', {'uuid': 'user-123'})
@pytest.mark.anyio('asyncio')
async def test_remnawave_webhook_rejects_payload_without_event() -> None:
bot = AsyncMock()
payload = {'data': {'uuid': 'user-123'}}
raw_body = json.dumps(payload).encode('utf-8')
router = create_remnawave_webhook_router(bot)
path = settings.REMNAWAVE_WEBHOOK_PATH
route = _get_route(router, path)
request = _build_request(
path,
raw_body,
headers={'X-Remnawave-Signature': _signature(raw_body)},
)
response = await route.endpoint(request)
assert response.status_code == 400
assert json.loads(response.body.decode('utf-8')) == {'status': 'error', 'reason': 'missing_event'}
def test_intentional_panel_deletion_guard_marks_and_detects() -> None:
"""Verify that mark + is_intentional round-trip works correctly."""
RemnaWaveWebhookService.mark_intentional_panel_deletion(
panel_uuids=['panel-user-123'],
telegram_id=8368498066,
)
assert RemnaWaveWebhookService._is_intentional_panel_deletion_event(
{'uuid': 'panel-user-123', 'telegramId': 8368498066}
)
# Unknown UUID should not match
assert not RemnaWaveWebhookService._is_intentional_panel_deletion_event(
{'uuid': 'unknown-uuid', 'telegramId': 99999}
)
def test_intentional_panel_deletion_guard_respects_hard_cap(monkeypatch: pytest.MonkeyPatch) -> None:
"""Verify that the guard stops accepting entries after hitting the cap."""
monkeypatch.setattr(RemnaWaveWebhookService, '_MAX_INTENTIONAL_ENTRIES', 3)
RemnaWaveWebhookService.mark_intentional_panel_deletion(panel_uuids=['a', 'b', 'c'])
# 3 entries — at capacity
RemnaWaveWebhookService.mark_intentional_panel_deletion(panel_uuids=['d'])
# 'd' should NOT be stored (cap reached)
assert 'd' not in RemnaWaveWebhookService._intentional_panel_deletions_by_uuid