Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eef41c4bca | |||
| 987c3c93c2 | |||
| 7d24e8d704 | |||
| 819f09a68e | |||
| 2f9d00343b | |||
| 9b7ac47f16 | |||
| 0d5638f778 | |||
| 7836720db3 | |||
| dcb90d6139 | |||
| 96c420e917 | |||
| 9d63635502 | |||
| c4c5d330be | |||
| 977950b97f | |||
| 6f6b9fa039 | |||
| 6713921887 | |||
| 2d42152f54 | |||
| 08ca947b2b | |||
| b04157c913 | |||
| f284351c51 | |||
| b607993854 | |||
| 246659032d | |||
| 3dc72b00e7 | |||
| 033d0da5e0 | |||
| 991f0b43e1 | |||
| c9524cb703 | |||
| 76d4a2124c | |||
| 4fa230c07f | |||
| d580a78403 | |||
| 312cc728a9 | |||
| b1820c651d | |||
| 0c284b9e99 | |||
| 72170b35f5 |
+1
-1
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "3.43.0"
|
||||
".": "3.45.0"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "✖️ بستن"
|
||||
}
|
||||
@@ -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": "✖️ Закрыть"
|
||||
}
|
||||
|
||||
@@ -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": "✖️ Закрити"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -944,6 +944,11 @@ class BotConfigurationService:
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
},
|
||||
'WEBHOOK_NOTIFY_TORRENT_DETECTED': {
|
||||
'description': 'Уведомление пользователю при обнаружении торрент-трафика.',
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
},
|
||||
'RESET_TRAFFIC_ON_TARIFF_SWITCH': {
|
||||
'description': (
|
||||
'Автоматически сбрасывает счётчик использованного трафика '
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user