Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bf4aeb31e | |||
| 7356921eeb | |||
| f24337fb41 | |||
| 69a38dad25 | |||
| aa3459b846 | |||
| 4d695be7d5 | |||
| b8fcbc7661 | |||
| 96042782d9 | |||
| a625eaae4f | |||
| 869fe06831 | |||
| a5fbd7400f | |||
| 995d66483b | |||
| 5c77bd7a0f | |||
| 04697fd4cb | |||
| c9f2dffabf | |||
| fe4e6acb53 | |||
| e24b911283 | |||
| b551def340 | |||
| 5e9a462261 | |||
| 3a3bd9d499 | |||
| 75dbd2b4fc | |||
| b4ef52caa4 | |||
| ae99358ae9 | |||
| 08bea704de | |||
| 18e2e7841a | |||
| 652b6dabde | |||
| c9a9816daa | |||
| 49c0f3fc10 | |||
| cb43acab31 | |||
| f59b215645 | |||
| 3efa24bab3 | |||
| bd2e93a6a5 | |||
| 978f68e7be | |||
| 28fc36dca4 | |||
| 1660b24f98 | |||
| acf27a1023 | |||
| ce82c2c009 | |||
| e6ebc6722d | |||
| 02e5401327 | |||
| c3bb63ffed | |||
| 88369eec50 | |||
| 83ca51cd5b | |||
| f9dad615ee | |||
| ba049ca017 | |||
| 585baaf63c | |||
| 04197817fe | |||
| b2ee6c766a | |||
| d35ee58aa6 | |||
| 815a1d9136 | |||
| b7775b72dc | |||
| ba54819f9c | |||
| 266340aad1 | |||
| 8f434525eb | |||
| efa1b11db5 | |||
| d0ce193edb | |||
| 92d872236f | |||
| a11f492801 | |||
| c8162505ed | |||
| 076290e0c1 | |||
| 2f5674fcd7 | |||
| b9058e115a | |||
| 673afccb8c | |||
| 1badb39c49 | |||
| 23ff40cd2c | |||
| bf72f241d8 | |||
| 12ae871653 | |||
| 8a362db783 | |||
| b1e2146254 | |||
| 68bc8eb57c | |||
| 9957259881 | |||
| b3f3eba575 | |||
| a798f1143e | |||
| cb5126aff8 | |||
| 8b35428055 | |||
| 5424d8c314 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.30.0"
|
||||
".": "3.32.1"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,92 @@
|
||||
# Changelog
|
||||
|
||||
## [3.32.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.0...v3.32.1) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* invalid ISO date format in node usage stats API call ([69a38da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69a38dad259bd05f4658e1014ce0bd73fc2e2ac5))
|
||||
* platega webhook ID fallback for SBP and card payments ([aa3459b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa3459b8463ce0a54b7709aa3547b2337064fa26))
|
||||
* resolve MissingGreenlet in switch_tariff endpoint ([4d695be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4d695be7d51adda40fa72c00c349fb0e1ec4acd2))
|
||||
|
||||
## [3.32.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.31.0...v3.32.0) (2026-03-13)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add _calculate_servers_price (fixed fallback) and _calculate_traffic_price ([88369ee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/88369eec5047e733d26d2450a74abd0d600b2e1b))
|
||||
* add CLASSIC_PERIOD_PRICES to config ([c3bb63f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c3bb63ffed6e0b684c322aa51d70ab7e71c8eb6b))
|
||||
* add LIMITED subscription status and preserve extra devices on tariff switch ([8f43452](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f434525eb14618e3c3e26261d443b1632c111bb))
|
||||
* add RenewalPricing dataclass and PricingEngine discount methods ([83ca51c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/83ca51cd5b040e747c6db904dde0f3a5c59f480f))
|
||||
* implement calculate_renewal_price with tariff and classic modes ([02e5401](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e5401327786c9dfe5ae7d4c89624c9455aa53e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing settings import in admin_users tariff switch ([b2ee6c7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2ee6c766a1fb0c9a701684a6349b970d12f5e2e))
|
||||
* add per-category discounts and months multiplier to classic mode ([1660b24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1660b24f9844374bbd156f9202a8e1550a6beb49))
|
||||
* add period_days whitelist validation and type annotations ([18e2e78](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/18e2e7841a6d614263e7c87db5964916ec869a9d))
|
||||
* address 6-agent review findings for PricingEngine ([c9f2dff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c9f2dffabf6369df360c5f9ad7a12c0415026310))
|
||||
* address review findings from 5-agent audit ([08bea70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/08bea704ded78102dce29deac8da95c4e4b9d815))
|
||||
* atomicity refactor, review fixes, and DELETED recovery logging ([ba54819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba54819f9cd7f60914dd472b68885683f435db4e))
|
||||
* change None assignment to [] + add "or []" guards at all 5 call sites. ([a5fbd74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a5fbd7400f828824c5baa520bdaf06023b4caf70))
|
||||
* downgrade known-harmless RemnaWave 400s to warning level ([0419781](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04197817fede058dc4688dce2f9877f0fc2a7f7f))
|
||||
* guard rollback on commit flag, add flush to promo_offer_log ([b7775b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b7775b72dc7a1b3d18f179c2f247fe9f47023347))
|
||||
* handle legacy telegram_id in YooKassa webhook recovery metadata ([815a1d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/815a1d9136f39b932d4b369aec9d67034d6785d9))
|
||||
* harden remnawave API error handling and YooKassa user cross-validation ([585baaf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/585baaf63c9f535e5311a32085d0187d8c854001))
|
||||
* harden YooKassa webhook recovery user lookup ([d35ee58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d35ee58aa6f3edc6a9e8ab43025569262acf64a2))
|
||||
* payment providers — lock_user_for_update + commit=False atomicity ([b4ef52c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b4ef52caa4b324eded8e6c6cb715a09ad59140c1))
|
||||
* prevent balance loss on auto-purchase for DISABLED subscriptions and fix WATA expiration ([266340a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/266340aad195995f208ed82fc11e0909d34898f4))
|
||||
* pricing audit — display/charge parity, race conditions, balance locks ([ae99358](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ae99358ae9f35a25370ab127d98a0b630a08e3f2))
|
||||
* resolve merge conflict with dev (accept calc_device_limit_on_tariff_switch) ([ba049ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba049ca017e004c25f8738f01b2d5f329a35bb5e))
|
||||
* user deletion FK error + connected_squads None TypeError ([a5fbd74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a5fbd7400f828824c5baa520bdaf06023b4caf70))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* add typed breakdowns + module-level singleton to PricingEngine ([b551def](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b551def3402e2bf762406fb0b374360958231bb3))
|
||||
* extract shared formatting helpers into app/utils/formatting.py ([5e9a462](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5e9a462261e46ee649de481266a821fd6793bf2e))
|
||||
* make finalize() accept both old and new pricing types ([3efa24b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3efa24bab3a2bd1d31103b134e502c10af8e41e1))
|
||||
* migrate admin user price calculation to PricingEngine ([49c0f3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49c0f3fc10d27092961601cf7f6a780fb56885fa))
|
||||
* migrate all callers to pricing_engine singleton + fix miniapp discount ([e24b911](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e24b911283bf4cbee7b18d3e49c935217e4a2863))
|
||||
* migrate bot renewal display to PricingEngine ([ce82c2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ce82c2c00988542ac73dc9d2e811711ea9cefebe))
|
||||
* migrate bot renewal execute to PricingEngine ([acf27a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acf27a102308d38676b2ccaef78016b56a80935d))
|
||||
* migrate cabinet renewal display + execute to PricingEngine ([28fc36d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28fc36dca41b626430baf823274561269023ac59))
|
||||
* migrate cart auto-purchase to PricingEngine (fresh calc) ([bd2e93a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd2e93a6a5076341b104f7dba2b7fc5fdb587e66))
|
||||
* migrate menu.py renewal pricing to PricingEngine ([652b6da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/652b6dabde014d19075f13198dd06e5fb8bef380))
|
||||
* migrate miniapp renewal display + execute to PricingEngine ([cb43aca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb43acab3194bbf9b6e2c04ca0254ba5b2571b2d))
|
||||
* migrate recurrent and monitoring services to PricingEngine ([978f68e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/978f68e7be42b0faf92d9ac5bc0bbaa2022ac95b))
|
||||
* migrate remaining callers to PricingEngine + cleanup dead CRUD ([75dbd2b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/75dbd2b4fcc8ab14ac44d915bf55b10406544bb1))
|
||||
* migrate try_auto_extend_expired to PricingEngine ([e6ebc67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e6ebc6722d826d291156e6bff3bf86000b32b783))
|
||||
* remove dead pricing code and fix miniapp classic mode ([c9a9816](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c9a9816daa15a4534a3990822543eeefe1a1631b))
|
||||
* unify first-purchase discount algorithm with PricingEngine ([fe4e6ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe4e6acb5391d0797ea01281eeb2e2ea59a0070f))
|
||||
|
||||
## [3.31.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.30.0...v3.31.0) (2026-03-12)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add show_in_gift toggle for tariffs in admin panel ([cb5126a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb5126aff8c15938a59ea9c4f8e605b250b05dbc))
|
||||
* add sync-squads endpoint for bulk updating subscription squads in Remnawave ([b1e2146](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b1e2146254255586b5be9bd894ac4d113a0a8cf5))
|
||||
* auto-sync squads to Remnawave when admin updates tariff ([076290e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/076290e0c1d81b610a7653d6b64ed218e0f124b4))
|
||||
* referral links now point to web cabinet instead of bot ([12ae871](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/12ae871653399bc4ccd23b6394878e814ce9cd75))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add post_update=True to User.referrals self-referential relationship ([9957259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/995725988150f31d193631120a4692e88fa4dd57))
|
||||
* add Telegram Stars payment support for gift subscriptions ([5424d8c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5424d8c31484873b0adc0bc980abdc51ee81325b))
|
||||
* correct skipped_count in sync-squads circuit breaker and simplify ternary ([8a362db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a362db7833b5b7793b5b52345d227cb84cbc39e))
|
||||
* preserve purchased devices when admin changes user tariff ([bf72f24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf72f241d81e4432f50a61ec3bb829d18c92955d))
|
||||
* prevent account takeover via auto_login_token, ensure promo group on all purchase paths ([b3f3eba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b3f3eba5756404df9ed0f12d8048244ca536f7d3))
|
||||
* reactivate subscription after traffic top-up when status is EXPIRED ([8b35428](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b354280558a5f28d1b99eae55ccd21a4af6a07b))
|
||||
* update promo group via M2M table so admin changes persist ([68bc8eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68bc8eb57c792059d2be8a8fff6bba3254d3773d))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove estimated price from balance, simplify server sync, fix HTML injection ([a798f11](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a798f1143eebf52e18254bddd610f7f14a0c4056))
|
||||
|
||||
## [3.30.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.29.0...v3.30.0) (2026-03-11)
|
||||
|
||||
|
||||
|
||||
+19
-32
@@ -197,28 +197,17 @@ async def create_subscription(
|
||||
### Документация кода
|
||||
|
||||
```python
|
||||
async def calculate_subscription_price(
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
devices_count: int,
|
||||
servers_count: int
|
||||
) -> int:
|
||||
"""
|
||||
Рассчитывает стоимость подписки.
|
||||
|
||||
Args:
|
||||
period_days: Период подписки в днях
|
||||
traffic_gb: Лимит трафика в ГБ (0 = безлимит)
|
||||
devices_count: Количество устройств
|
||||
servers_count: Количество серверов
|
||||
|
||||
Returns:
|
||||
Стоимость в копейках
|
||||
|
||||
Raises:
|
||||
ValueError: Если переданы некорректные параметры
|
||||
"""
|
||||
# implementation
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
pricing = PricingEngine.calculate_renewal_price(
|
||||
subscription=subscription,
|
||||
period_days=30,
|
||||
user=user,
|
||||
)
|
||||
# pricing.final_total — стоимость в копейках
|
||||
# pricing.original_total — цена до скидок
|
||||
# pricing.promo_group_discount — скидка промогруппы
|
||||
# pricing.promo_offer_discount — скидка промо-оффера
|
||||
```
|
||||
|
||||
### Обработка ошибок
|
||||
@@ -341,20 +330,18 @@ python main.py
|
||||
### Тестирование компонентов
|
||||
|
||||
```python
|
||||
# tests/test_subscription_service.py
|
||||
# tests/services/test_pricing_engine.py
|
||||
import pytest
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_price():
|
||||
price = await SubscriptionService.calculate_subscription_price(
|
||||
def test_calculate_renewal_price():
|
||||
pricing = PricingEngine.calculate_renewal_price(
|
||||
subscription=mock_subscription,
|
||||
period_days=30,
|
||||
traffic_gb=100,
|
||||
devices_count=3,
|
||||
servers_count=1
|
||||
user=mock_user,
|
||||
)
|
||||
assert price > 0
|
||||
assert isinstance(price, int)
|
||||
assert pricing.final_total > 0
|
||||
assert isinstance(pricing.final_total, int)
|
||||
```
|
||||
|
||||
### Integration тесты
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.30.0" # x-release-please-version
|
||||
ARG VERSION="v3.32.1" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Admin routes for managing tariffs in cabinet."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.tariff import (
|
||||
@@ -17,7 +20,7 @@ from app.database.crud.tariff import (
|
||||
set_tariff_promo_groups,
|
||||
update_tariff,
|
||||
)
|
||||
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
|
||||
from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Tariff, Transaction, TransactionType, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.tariffs import (
|
||||
@@ -26,6 +29,7 @@ from ..schemas.tariffs import (
|
||||
PromoGroupInfo,
|
||||
ServerInfo,
|
||||
ServerTrafficLimit,
|
||||
SyncSquadsResponse,
|
||||
TariffCreateRequest,
|
||||
TariffDetailResponse,
|
||||
TariffListItem,
|
||||
@@ -127,6 +131,7 @@ async def list_tariffs(
|
||||
is_daily=tariff.is_daily,
|
||||
daily_price_kopeks=tariff.daily_price_kopeks,
|
||||
allow_traffic_topup=tariff.allow_traffic_topup,
|
||||
show_in_gift=tariff.show_in_gift,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
tier_level=tariff.tier_level,
|
||||
@@ -265,6 +270,8 @@ async def get_tariff(
|
||||
traffic_reset_mode=tariff.traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
external_squad_uuid=tariff.external_squad_uuid,
|
||||
# Показывать в подарках
|
||||
show_in_gift=tariff.show_in_gift,
|
||||
created_at=tariff.created_at,
|
||||
updated_at=tariff.updated_at,
|
||||
)
|
||||
@@ -321,6 +328,8 @@ async def create_new_tariff(
|
||||
traffic_reset_mode=request.traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
external_squad_uuid=request.external_squad_uuid,
|
||||
# Показывать в подарках
|
||||
show_in_gift=request.show_in_gift,
|
||||
)
|
||||
|
||||
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
|
||||
@@ -347,6 +356,10 @@ async def update_existing_tariff(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Capture old values for change detection
|
||||
old_squads = list(tariff.allowed_squads) if tariff.allowed_squads else []
|
||||
old_external_squad = tariff.external_squad_uuid
|
||||
|
||||
# Build updates dict
|
||||
updates = {}
|
||||
if request.name is not None:
|
||||
@@ -413,6 +426,9 @@ async def update_existing_tariff(
|
||||
# Внешний сквад (None допускается для сброса)
|
||||
if 'external_squad_uuid' in request.model_fields_set:
|
||||
updates['external_squad_uuid'] = request.external_squad_uuid
|
||||
# Показывать в подарках
|
||||
if request.show_in_gift is not None:
|
||||
updates['show_in_gift'] = request.show_in_gift
|
||||
|
||||
if updates:
|
||||
await update_tariff(db, tariff, **updates)
|
||||
@@ -426,6 +442,18 @@ async def update_existing_tariff(
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
# Auto-sync squads to active subscriptions in Remnawave when squads changed
|
||||
new_squads = tariff.allowed_squads or []
|
||||
squads_changed = request.allowed_squads is not None and sorted(old_squads) != sorted(new_squads)
|
||||
ext_squad_changed = (
|
||||
'external_squad_uuid' in request.model_fields_set and tariff.external_squad_uuid != old_external_squad
|
||||
)
|
||||
if squads_changed or ext_squad_changed:
|
||||
asyncio.create_task(
|
||||
_background_sync_squads(tariff_id, admin.id),
|
||||
name=f'sync-squads-tariff-{tariff_id}',
|
||||
)
|
||||
|
||||
return await get_tariff(tariff_id, admin, db)
|
||||
|
||||
|
||||
@@ -586,3 +614,217 @@ async def get_tariff_stats(
|
||||
revenue_kopeks=revenue_kopeks,
|
||||
revenue_rubles=revenue_kopeks / 100,
|
||||
)
|
||||
|
||||
|
||||
async def _background_sync_squads(tariff_id: int, admin_id: int) -> None:
|
||||
"""Run squad sync in background with its own DB session (fire-and-forget)."""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(joinedload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
User.remnawave_uuid.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
subscriptions = list(result.unique().scalars().all())
|
||||
|
||||
if not subscriptions:
|
||||
return
|
||||
|
||||
new_squads = tariff.allowed_squads or []
|
||||
ext_squad_uuid = tariff.external_squad_uuid
|
||||
|
||||
service = RemnaWaveService()
|
||||
updated = 0
|
||||
failed = 0
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
semaphore = asyncio.Semaphore(5)
|
||||
|
||||
async def _sync_one(sub: Subscription) -> None:
|
||||
nonlocal updated, failed
|
||||
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
|
||||
if not remnawave_uuid:
|
||||
return
|
||||
async with semaphore:
|
||||
try:
|
||||
await api.update_user(
|
||||
uuid=remnawave_uuid,
|
||||
active_internal_squads=new_squads,
|
||||
external_squad_uuid=ext_squad_uuid,
|
||||
)
|
||||
sub.connected_squads = new_squads
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.warning(
|
||||
'Background sync: failed to sync squads for user',
|
||||
user_id=sub.user_id,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'Background squad sync completed after tariff update',
|
||||
admin_id=admin_id,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total=len(subscriptions),
|
||||
updated=updated,
|
||||
failed=failed,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Background squad sync failed', tariff_id=tariff_id)
|
||||
|
||||
|
||||
_SYNC_SQUADS_CONCURRENCY = 5
|
||||
_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES = 10
|
||||
|
||||
|
||||
@router.post('/{tariff_id}/sync-squads', response_model=SyncSquadsResponse)
|
||||
async def sync_tariff_squads(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Sync squads from tariff to all active/trial subscriptions in Remnawave panel.
|
||||
|
||||
Updates connected_squads and external_squad_uuid for every active or trial
|
||||
subscription linked to this tariff. Only users that have a remnawave_uuid
|
||||
(i.e. already exist in the panel) are touched.
|
||||
"""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Fetch active + trial subscriptions for this tariff whose users exist in Remnawave
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(joinedload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
User.remnawave_uuid.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
subscriptions = list(result.unique().scalars().all())
|
||||
|
||||
if not subscriptions:
|
||||
return SyncSquadsResponse(
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total_subscriptions=0,
|
||||
updated_count=0,
|
||||
failed_count=0,
|
||||
skipped_count=0,
|
||||
)
|
||||
|
||||
new_squads = tariff.allowed_squads or []
|
||||
# None means "clear external squad" — intentional when tariff has none
|
||||
ext_squad_uuid = tariff.external_squad_uuid
|
||||
|
||||
# Sync to Remnawave panel with concurrency limit and circuit breaker
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
updated_count = 0
|
||||
failed_count = 0
|
||||
skipped_count = 0
|
||||
consecutive_failures = 0
|
||||
errors: list[str] = []
|
||||
aborted = False
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
semaphore = asyncio.Semaphore(_SYNC_SQUADS_CONCURRENCY)
|
||||
|
||||
async def _sync_one(sub: Subscription) -> str:
|
||||
# Counter mutations are safe: no `await` between read-modify-write
|
||||
# and the check within each branch (single-threaded asyncio event loop).
|
||||
nonlocal updated_count, failed_count, skipped_count, consecutive_failures, aborted
|
||||
|
||||
if aborted:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
|
||||
if not remnawave_uuid:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
async with semaphore:
|
||||
if aborted:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
try:
|
||||
await api.update_user(
|
||||
uuid=remnawave_uuid,
|
||||
active_internal_squads=new_squads,
|
||||
external_squad_uuid=ext_squad_uuid,
|
||||
)
|
||||
# Update local DB only on successful API call
|
||||
sub.connected_squads = new_squads
|
||||
updated_count += 1
|
||||
consecutive_failures = 0
|
||||
return 'ok'
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
consecutive_failures += 1
|
||||
errors.append(f'user_id={sub.user_id}: sync failed')
|
||||
logger.warning(
|
||||
'Failed to sync squads for user in Remnawave',
|
||||
user_id=sub.user_id,
|
||||
remnawave_uuid=remnawave_uuid,
|
||||
error=str(e),
|
||||
)
|
||||
if consecutive_failures >= _SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES:
|
||||
aborted = True
|
||||
errors.append(f'Aborted after {_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES} consecutive failures')
|
||||
return 'error'
|
||||
|
||||
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
|
||||
|
||||
# Commit local DB changes only for successfully synced subscriptions
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin synced squads for tariff',
|
||||
admin_id=admin.id,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total=len(subscriptions),
|
||||
updated=updated_count,
|
||||
failed=failed_count,
|
||||
skipped=skipped_count,
|
||||
)
|
||||
|
||||
return SyncSquadsResponse(
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total_subscriptions=len(subscriptions),
|
||||
updated_count=updated_count,
|
||||
failed_count=failed_count,
|
||||
skipped_count=skipped_count,
|
||||
errors=errors[:20],
|
||||
)
|
||||
|
||||
@@ -4,10 +4,11 @@ from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import Integer, and_, func, or_, select
|
||||
from sqlalchemy import Integer, and_, delete as sa_delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.campaign import get_campaign_registration_by_user
|
||||
from app.database.crud.subscription import (
|
||||
extend_subscription,
|
||||
@@ -25,6 +26,7 @@ from app.database.crud.user import (
|
||||
get_users_statistics,
|
||||
subtract_user_balance,
|
||||
)
|
||||
from app.database.crud.user_promo_group import sync_user_primary_promo_group
|
||||
from app.database.models import (
|
||||
GuestPurchase,
|
||||
PromoGroup,
|
||||
@@ -36,6 +38,7 @@ from app.database.models import (
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
UserPromoGroup,
|
||||
UserStatus,
|
||||
)
|
||||
from app.utils.timezone import panel_datetime_to_utc
|
||||
@@ -1119,9 +1122,19 @@ async def update_user_subscription(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
|
||||
|
||||
subscription.tariff_id = request.tariff_id
|
||||
subscription.traffic_limit_gb = tariff.traffic_limit_gb
|
||||
subscription.device_limit = tariff.device_limit
|
||||
subscription.device_limit = calc_device_limit_on_tariff_switch(
|
||||
current_device_limit=subscription.device_limit,
|
||||
old_tariff_device_limit=old_tariff.device_limit if old_tariff else None,
|
||||
new_tariff_device_limit=tariff.device_limit,
|
||||
max_device_limit=tariff.max_device_limit,
|
||||
)
|
||||
# Set squads from tariff
|
||||
if tariff.allowed_squads:
|
||||
subscription.connected_squads = tariff.allowed_squads
|
||||
@@ -1140,8 +1153,6 @@ async def update_user_subscription(
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None
|
||||
|
||||
from app.config import settings
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
|
||||
@@ -1268,7 +1279,7 @@ async def update_user_subscription(
|
||||
|
||||
await add_subscription_traffic(db, subscription, request.traffic_gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
await db.refresh(subscription)
|
||||
@@ -1276,6 +1287,13 @@ async def update_user_subscription(
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
logger.info('Admin added traffic for user', admin_id=admin.id, traffic_gb=request.traffic_gb, user_id=user_id)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
@@ -1629,8 +1647,22 @@ async def update_user_promo_group(
|
||||
)
|
||||
promo_group_name = promo_group.name
|
||||
|
||||
user.promo_group_id = new_promo_group_id
|
||||
user.updated_at = datetime.now(UTC)
|
||||
# Update M2M table (authoritative source) — not just the legacy FK column.
|
||||
# Without this, sync_user_primary_promo_group overwrites the admin change
|
||||
# on the next transaction.
|
||||
await db.execute(sa_delete(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
|
||||
|
||||
if new_promo_group_id is not None:
|
||||
db.add(
|
||||
UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=new_promo_group_id,
|
||||
assigned_by='admin',
|
||||
)
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
await sync_user_primary_promo_group(db, user_id)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -2484,8 +2516,25 @@ async def sync_user_from_panel(
|
||||
if panel_user.expire_at:
|
||||
panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at)
|
||||
|
||||
sub_end_utc = sub.end_date if sub.end_date and sub.end_date.tzinfo else sub.end_date
|
||||
sub_end_utc = sub.end_date
|
||||
if sub_end_utc is not None and sub_end_utc.tzinfo is None:
|
||||
sub_end_utc = sub_end_utc.replace(tzinfo=UTC)
|
||||
if sub_end_utc != panel_expire_utc:
|
||||
# Предупреждаем если локальная дата новее панельной
|
||||
# (например, автопокупка уже продлила подписку)
|
||||
if sub_end_utc and panel_expire_utc and sub_end_utc > panel_expire_utc:
|
||||
logger.warning(
|
||||
'Sync: локальная end_date новее панельной, перезаписываем. '
|
||||
'Возможно автопокупка уже продлила подписку.',
|
||||
user_id=user_id,
|
||||
local_end_date=sub_end_utc.isoformat(),
|
||||
panel_expire_at=panel_expire_utc.isoformat(),
|
||||
)
|
||||
errors.append(
|
||||
f'Warning: local end_date ({sub_end_utc.isoformat()}) is newer than '
|
||||
f'panel expire_at ({panel_expire_utc.isoformat()}). '
|
||||
f'Panel value applied — check if auto-purchase extended subscription.'
|
||||
)
|
||||
changes['end_date'] = {
|
||||
'old': sub.end_date.isoformat() if sub.end_date else None,
|
||||
'new': panel_expire_utc.isoformat(),
|
||||
|
||||
@@ -86,6 +86,7 @@ def _user_allowed(subscription) -> bool:
|
||||
return subscription.status in {
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +122,9 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
|
||||
if not user:
|
||||
return 'Error: user not found'
|
||||
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
user.balance_kopeks += int(round(amount * 100))
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -82,9 +82,11 @@ async def get_gift_config(
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
)
|
||||
|
||||
# Load active tariffs
|
||||
# Load active tariffs visible in gift section
|
||||
result = await db.execute(
|
||||
select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.display_order, Tariff.id)
|
||||
select(Tariff)
|
||||
.where(Tariff.is_active.is_(True), Tariff.show_in_gift.is_(True))
|
||||
.order_by(Tariff.display_order, Tariff.id)
|
||||
)
|
||||
tariffs_db = result.scalars().all()
|
||||
|
||||
@@ -241,7 +243,7 @@ async def create_gift_purchase(
|
||||
|
||||
# Find tariff and validate period
|
||||
tariff = await get_tariff_by_id(db, body.tariff_id)
|
||||
if tariff is None or not tariff.is_active:
|
||||
if tariff is None or not tariff.is_active or not tariff.show_in_gift:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found or inactive',
|
||||
@@ -380,7 +382,14 @@ async def create_gift_purchase(
|
||||
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
payment_service = PaymentService()
|
||||
# Stars payments need a Bot instance to create invoice links
|
||||
bot = None
|
||||
if body.payment_method == 'telegram_stars':
|
||||
from aiogram import Bot
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
|
||||
payment_service = PaymentService(bot=bot)
|
||||
payment_result = await payment_service.create_guest_payment(
|
||||
db=db,
|
||||
amount_kopeks=price_kopeks,
|
||||
|
||||
@@ -607,6 +607,13 @@ async def create_landing_purchase(
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
# Gift purchases require the tariff to be visible in the gift section
|
||||
if body.is_gift and not tariff.show_in_gift:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This tariff is not available for gift purchases',
|
||||
)
|
||||
|
||||
# Validate amount against per-method min/max limits (before creating purchase record)
|
||||
min_amount = method_config.get('min_amount_kopeks')
|
||||
max_amount = method_config.get('max_amount_kopeks')
|
||||
|
||||
@@ -92,8 +92,7 @@ async def get_referral_info(
|
||||
available_balance = min(user.balance_kopeks, referral_entitlement)
|
||||
|
||||
# Build referral link
|
||||
bot_username = settings.get_bot_username() or 'bot'
|
||||
referral_link = f'https://t.me/{bot_username}?start={user.referral_code}'
|
||||
referral_link = settings.get_referral_link(user.referral_code) if user.referral_code else ''
|
||||
|
||||
return ReferralInfoResponse(
|
||||
referral_code=user.referral_code or '',
|
||||
|
||||
+136
-233
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import PERIOD_PRICES, settings
|
||||
from app.config import settings
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
from app.database.crud.subscription import (
|
||||
create_paid_subscription,
|
||||
@@ -26,12 +26,17 @@ from app.services.notification_delivery_service import (
|
||||
NotificationType,
|
||||
notification_delivery_service,
|
||||
)
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_purchase_service import (
|
||||
MiniAppSubscriptionPurchaseService,
|
||||
PurchaseBalanceError,
|
||||
PurchaseValidationError,
|
||||
)
|
||||
from app.services.subscription_renewal_service import (
|
||||
SubscriptionRenewalChargeError,
|
||||
SubscriptionRenewalService,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
@@ -141,6 +146,7 @@ def _subscription_to_response(
|
||||
actual_status = subscription.actual_status
|
||||
is_expired = actual_status == 'expired'
|
||||
is_active = actual_status in ('active', 'trial')
|
||||
is_limited = actual_status == 'limited'
|
||||
|
||||
# Calculate time remaining
|
||||
days_left = 0
|
||||
@@ -235,6 +241,7 @@ def _subscription_to_response(
|
||||
hide_subscription_link=hide_link,
|
||||
is_active=is_active,
|
||||
is_expired=is_expired,
|
||||
is_limited=is_limited,
|
||||
traffic_purchases=traffic_purchases or [],
|
||||
is_daily=is_daily,
|
||||
is_daily_paused=is_daily_paused,
|
||||
@@ -324,77 +331,34 @@ async def get_renewal_options(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get available subscription renewal options with prices."""
|
||||
options = []
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if not subscription:
|
||||
return []
|
||||
|
||||
# В режиме тарифов берём цены из тарифа пользователя
|
||||
tariff_prices = None
|
||||
tariff_periods = None
|
||||
extra_devices = 0
|
||||
tariff_device_price = 0
|
||||
if settings.is_tariffs_mode():
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if subscription and subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and tariff.period_prices:
|
||||
tariff_prices = {int(k): v for k, v in tariff.period_prices.items()}
|
||||
tariff_periods = sorted(tariff_prices.keys())
|
||||
# Учитываем докупленные устройства сверх тарифа
|
||||
extra_devices = max(0, (subscription.device_limit or 0) - (tariff.device_limit or 0))
|
||||
if extra_devices > 0:
|
||||
tariff_device_price = (
|
||||
tariff.device_price_kopeks
|
||||
if tariff.device_price_kopeks is not None
|
||||
else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
|
||||
# Используем периоды тарифа или стандартные
|
||||
if tariff_periods:
|
||||
periods = tariff_periods
|
||||
# Determine available periods
|
||||
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
|
||||
periods = sorted(int(k) for k in subscription.tariff.period_prices.keys())
|
||||
else:
|
||||
periods = settings.get_available_renewal_periods()
|
||||
|
||||
for period in periods:
|
||||
# Получаем цену из тарифа или из PERIOD_PRICES
|
||||
if tariff_prices and period in tariff_prices:
|
||||
price_kopeks = tariff_prices[period]
|
||||
else:
|
||||
price_kopeks = PERIOD_PRICES.get(period, 0)
|
||||
options = []
|
||||
|
||||
if price_kopeks <= 0:
|
||||
for period in periods:
|
||||
pricing = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
|
||||
|
||||
if pricing.final_total <= 0 and pricing.base_price <= 0:
|
||||
continue
|
||||
|
||||
# Добавляем стоимость докупленных устройств за период продления
|
||||
if extra_devices > 0 and tariff_device_price > 0:
|
||||
from app.utils.pricing_utils import calculate_months_from_days
|
||||
|
||||
months = calculate_months_from_days(period)
|
||||
price_kopeks += extra_devices * tariff_device_price * months
|
||||
|
||||
# Apply user's discount if any
|
||||
original_price = price_kopeks
|
||||
discount_percent = 0
|
||||
if hasattr(user, 'get_promo_discount'):
|
||||
discount_percent = user.get_promo_discount('period', period)
|
||||
|
||||
if discount_percent > 0:
|
||||
price_kopeks = int(price_kopeks * (100 - discount_percent) / 100)
|
||||
|
||||
# Apply promo_offer discount (временная скидка, как в /renew)
|
||||
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
if promo_offer_discount_percent > 0:
|
||||
price_kopeks = price_kopeks - price_kopeks * promo_offer_discount_percent // 100
|
||||
|
||||
# Комбинированный процент скидки для отображения
|
||||
combined_discount = discount_percent
|
||||
if original_price > 0 and original_price != price_kopeks:
|
||||
total_discount = original_price - price_kopeks
|
||||
combined_discount = int(total_discount * 100 / original_price)
|
||||
original_price = pricing.original_total
|
||||
combined_discount = 0
|
||||
if original_price > 0 and original_price != pricing.final_total:
|
||||
combined_discount = int((original_price - pricing.final_total) * 100 / original_price)
|
||||
|
||||
options.append(
|
||||
RenewalOptionResponse(
|
||||
period_days=period,
|
||||
price_kopeks=price_kopeks,
|
||||
price_rubles=price_kopeks / 100,
|
||||
price_kopeks=pricing.final_total,
|
||||
price_rubles=pricing.final_total / 100,
|
||||
discount_percent=combined_discount,
|
||||
original_price_kopeks=original_price if combined_discount > 0 else None,
|
||||
)
|
||||
@@ -424,57 +388,42 @@ async def renew_subscription(
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
# В режиме тарифов берём цену из тарифа пользователя
|
||||
price_kopeks = 0
|
||||
tariff = None
|
||||
if settings.is_tariffs_mode() and user.subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, user.subscription.tariff_id)
|
||||
if tariff and tariff.period_prices:
|
||||
price_kopeks = tariff.period_prices.get(str(request.period_days), 0)
|
||||
# Validate period_days against available periods (prevent arbitrary periods)
|
||||
subscription = user.subscription
|
||||
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
|
||||
available_periods = [int(p) for p in subscription.tariff.period_prices.keys()]
|
||||
else:
|
||||
available_periods = settings.get_available_renewal_periods()
|
||||
|
||||
# Fallback на PERIOD_PRICES
|
||||
if price_kopeks <= 0:
|
||||
price_kopeks = PERIOD_PRICES.get(request.period_days, 0)
|
||||
if request.period_days not in available_periods:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Selected renewal period is not available',
|
||||
)
|
||||
|
||||
if price_kopeks <= 0:
|
||||
# Unified pricing via PricingEngine
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
subscription,
|
||||
request.period_days,
|
||||
user=user,
|
||||
)
|
||||
price_kopeks = pricing.final_total
|
||||
promo_offer_discount_value = pricing.promo_offer_discount
|
||||
promo_offer_discount_percent = pricing.breakdown.get('offer_discount_pct', 0)
|
||||
|
||||
if price_kopeks <= 0 and pricing.base_price <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid renewal period',
|
||||
)
|
||||
|
||||
# Добавляем стоимость докупленных устройств сверх тарифа
|
||||
if tariff:
|
||||
extra_devices = max(0, (user.subscription.device_limit or 0) - (tariff.device_limit or 0))
|
||||
if extra_devices > 0:
|
||||
from app.utils.pricing_utils import calculate_months_from_days
|
||||
original_price_kopeks = pricing.original_total
|
||||
discount_percent = 0
|
||||
if original_price_kopeks > 0 and original_price_kopeks != price_kopeks:
|
||||
discount_percent = int((original_price_kopeks - price_kopeks) * 100 / original_price_kopeks)
|
||||
|
||||
device_price = (
|
||||
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
months = calculate_months_from_days(request.period_days)
|
||||
price_kopeks += extra_devices * device_price * months
|
||||
|
||||
# Apply promo group discount
|
||||
original_price_kopeks = price_kopeks
|
||||
promo_group_discount_percent = 0
|
||||
if hasattr(user, 'get_promo_discount'):
|
||||
promo_group_discount_percent = user.get_promo_discount('period', request.period_days)
|
||||
|
||||
if promo_group_discount_percent > 0:
|
||||
price_kopeks = int(price_kopeks * (100 - promo_group_discount_percent) / 100)
|
||||
|
||||
# Apply promo offer discount (temporary discount from promo offers)
|
||||
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
promo_offer_discount_value = 0
|
||||
if promo_offer_discount_percent > 0:
|
||||
promo_offer_discount_value = price_kopeks * promo_offer_discount_percent // 100
|
||||
price_kopeks = price_kopeks - promo_offer_discount_value
|
||||
|
||||
# Combined discount percent for display
|
||||
discount_percent = promo_group_discount_percent
|
||||
if promo_offer_discount_percent > 0 and original_price_kopeks > 0:
|
||||
total_discount = original_price_kopeks - price_kopeks
|
||||
discount_percent = int(total_discount * 100 / original_price_kopeks)
|
||||
tariff = user.subscription.tariff if user.subscription.tariff_id else None
|
||||
|
||||
# Check balance
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
@@ -539,19 +488,21 @@ async def renew_subscription(
|
||||
},
|
||||
)
|
||||
|
||||
# Deduct balance (centralized: row-level lock, promo consumption, paid subscription flag)
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
# Centralized renewal: balance deduction, extension, RemnaWave sync, admin notification,
|
||||
# server price recording, and compensating refund on failure.
|
||||
renewal_description = f'Продление подписки на {request.period_days} дней' + (f' ({tariff.name})' if tariff else '')
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
price_kopeks,
|
||||
renewal_description,
|
||||
consume_promo_offer=promo_offer_discount_value > 0,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
renewal_service = SubscriptionRenewalService()
|
||||
|
||||
try:
|
||||
result = await renewal_service.finalize(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
pricing,
|
||||
description=renewal_description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
except SubscriptionRenewalChargeError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
@@ -560,104 +511,9 @@ async def renew_subscription(
|
||||
},
|
||||
)
|
||||
|
||||
# Создаём транзакцию для учёта списания
|
||||
transaction = await create_transaction(
|
||||
db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price_kopeks,
|
||||
description=renewal_description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
await db.refresh(user, ['subscription'])
|
||||
|
||||
# Extend from end_date or now if expired
|
||||
now = datetime.now(UTC)
|
||||
was_expired = user.subscription.status in ('expired', 'disabled') or (
|
||||
user.subscription.end_date is not None and user.subscription.end_date <= now
|
||||
)
|
||||
|
||||
if user.subscription.end_date and user.subscription.end_date > now:
|
||||
user.subscription.end_date = user.subscription.end_date + timedelta(days=request.period_days)
|
||||
else:
|
||||
user.subscription.end_date = now + timedelta(days=request.period_days)
|
||||
user.subscription.start_date = now
|
||||
|
||||
user.subscription.status = 'active'
|
||||
user.subscription.is_trial = False
|
||||
|
||||
# При продлении истёкшей подписки — сбрасываем докупки трафика (новый период)
|
||||
if was_expired:
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == user.subscription.id))
|
||||
purchased = user.subscription.purchased_traffic_gb or 0
|
||||
if purchased > 0:
|
||||
old_traffic = user.subscription.traffic_limit_gb
|
||||
user.subscription.traffic_limit_gb = max(0, (user.subscription.traffic_limit_gb or 0) - purchased)
|
||||
logger.info(
|
||||
'Сброс докупок трафика при продлении истёкшей подписки',
|
||||
old_traffic=old_traffic,
|
||||
new_traffic=user.subscription.traffic_limit_gb,
|
||||
)
|
||||
user.subscription.purchased_traffic_gb = 0
|
||||
user.subscription.traffic_reset_at = None
|
||||
if settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
user.subscription.traffic_used_gb = 0.0
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Синхронизируем с RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
if getattr(user, 'remnawave_uuid', None):
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
user.subscription,
|
||||
reset_traffic=was_expired and settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason='subscription renewal (cabinet)',
|
||||
)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
user.subscription,
|
||||
reset_traffic=was_expired and settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason='subscription renewal (cabinet)',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync subscription renewal with RemnaWave', error=e)
|
||||
|
||||
# Отправляем уведомление админам о продлении подписки
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db=db,
|
||||
user=user,
|
||||
subscription=user.subscription,
|
||||
transaction=transaction,
|
||||
period_days=request.period_days,
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=price_kopeks,
|
||||
purchase_type='renewal',
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send admin notification for subscription renewal', error=e)
|
||||
|
||||
response = {
|
||||
'message': 'Subscription renewed successfully',
|
||||
'new_end_date': user.subscription.end_date.isoformat(),
|
||||
'new_end_date': result.subscription.end_date.isoformat(),
|
||||
'amount_paid_kopeks': price_kopeks,
|
||||
}
|
||||
|
||||
@@ -934,7 +790,7 @@ async def purchase_traffic(
|
||||
# Добавляем трафик (add_subscription_traffic обновляет purchased_traffic_gb, traffic_reset_at и коммитит)
|
||||
await add_subscription_traffic(db, subscription, request.gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -944,6 +800,9 @@ async def purchase_traffic(
|
||||
subscription_service = SubscriptionService()
|
||||
if getattr(user, 'remnawave_uuid', None):
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
@@ -4172,6 +4031,16 @@ async def switch_tariff(
|
||||
detail='No active subscription with tariff',
|
||||
)
|
||||
|
||||
# Lock subscription row to prevent concurrent tariff switches
|
||||
locked_result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(Subscription.id == user.subscription.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
subscription = locked_result.scalar_one()
|
||||
user.subscription = subscription
|
||||
|
||||
# Use actual_status for correct status check (handles time-based expiration)
|
||||
actual_status = user.subscription.actual_status
|
||||
if actual_status == 'expired':
|
||||
@@ -4334,6 +4203,7 @@ async def switch_tariff(
|
||||
upgrade_cost,
|
||||
description,
|
||||
mark_as_paid_subscription=True,
|
||||
commit=False,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
@@ -4341,7 +4211,7 @@ async def switch_tariff(
|
||||
detail='Failed to charge balance',
|
||||
)
|
||||
|
||||
# Create transaction
|
||||
# Create transaction (commit=False to keep FOR UPDATE lock held)
|
||||
switch_transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
@@ -4349,6 +4219,7 @@ async def switch_tariff(
|
||||
amount_kopeks=upgrade_cost,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
commit=False,
|
||||
)
|
||||
else:
|
||||
# Free switch (downgrade) — record in history
|
||||
@@ -4359,54 +4230,85 @@ async def switch_tariff(
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=0,
|
||||
description=description,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Update subscription
|
||||
old_tariff_name = current_tariff.name if current_tariff else 'Unknown'
|
||||
user.subscription.tariff_id = new_tariff.id
|
||||
user.subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
|
||||
user.subscription.device_limit = new_tariff.device_limit
|
||||
user.subscription.connected_squads = new_tariff.allowed_squads or []
|
||||
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
# Re-load subscription to avoid MissingGreenlet from expired lazy relationship
|
||||
# (subtract_user_balance re-selects User with populate_existing=True which expires relationships)
|
||||
await db.refresh(user, ['subscription'])
|
||||
subscription = user.subscription
|
||||
|
||||
subscription.tariff_id = new_tariff.id
|
||||
subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
|
||||
subscription.device_limit = calc_device_limit_on_tariff_switch(
|
||||
current_device_limit=subscription.device_limit,
|
||||
old_tariff_device_limit=current_tariff.device_limit if current_tariff else None,
|
||||
new_tariff_device_limit=new_tariff.device_limit,
|
||||
max_device_limit=new_tariff.max_device_limit,
|
||||
)
|
||||
subscription.connected_squads = new_tariff.allowed_squads or []
|
||||
|
||||
# Reset purchased traffic and delete TrafficPurchase records on tariff switch
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == user.subscription.id))
|
||||
user.subscription.purchased_traffic_gb = 0
|
||||
user.subscription.traffic_reset_at = None
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
|
||||
user.subscription.traffic_used_gb = 0.0
|
||||
subscription.traffic_used_gb = 0.0
|
||||
|
||||
if switching_to_daily:
|
||||
# Switching TO daily - reset end_date to 1 day, set last_daily_charge_at
|
||||
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
user.subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
user.subscription.is_daily_paused = False
|
||||
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
subscription.is_daily_paused = False
|
||||
elif switching_from_daily:
|
||||
user.subscription.end_date = datetime.now(UTC) + timedelta(days=new_period_days)
|
||||
user.subscription.is_daily_paused = False
|
||||
subscription.end_date = datetime.now(UTC) + timedelta(days=new_period_days)
|
||||
subscription.is_daily_paused = False
|
||||
|
||||
user.subscription.updated_at = datetime.now(UTC)
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
if upgrade_cost > 0 and switch_transaction:
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
switch_transaction,
|
||||
amount_kopeks=upgrade_cost,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
# Sync with RemnaWave (optionally reset traffic based on admin setting)
|
||||
should_reset_traffic = settings.RESET_TRAFFIC_ON_TARIFF_SWITCH
|
||||
# Refresh subscription after commit (all objects are expired)
|
||||
await db.refresh(subscription)
|
||||
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
if getattr(user, 'remnawave_uuid', None):
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
user.subscription,
|
||||
subscription,
|
||||
reset_traffic=should_reset_traffic,
|
||||
reset_reason='смена тарифа',
|
||||
)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
user.subscription,
|
||||
subscription,
|
||||
reset_traffic=should_reset_traffic,
|
||||
reset_reason='смена тарифа',
|
||||
)
|
||||
@@ -4426,7 +4328,7 @@ async def switch_tariff(
|
||||
logger.error('Failed to reset devices on tariff switch', error=e)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(user.subscription)
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Отправляем уведомление админам о смене тарифа
|
||||
try:
|
||||
@@ -4441,7 +4343,7 @@ async def switch_tariff(
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db=db,
|
||||
user=user,
|
||||
subscription=user.subscription,
|
||||
subscription=subscription,
|
||||
transaction=switch_transaction if upgrade_cost > 0 else None,
|
||||
period_days=remaining_days if remaining_days > 0 else new_period_days,
|
||||
was_trial_conversion=False,
|
||||
@@ -4457,7 +4359,7 @@ async def switch_tariff(
|
||||
'success': True,
|
||||
'message': f"Switched from '{old_tariff_name}' to '{new_tariff.name}'"
|
||||
+ (' (devices reset)' if devices_reset else ''),
|
||||
'subscription': _subscription_to_response(user.subscription),
|
||||
'subscription': _subscription_to_response(subscription),
|
||||
'old_tariff_name': old_tariff_name,
|
||||
'new_tariff_id': new_tariff.id,
|
||||
'new_tariff_name': new_tariff.name,
|
||||
@@ -4513,6 +4415,7 @@ async def toggle_subscription_pause(
|
||||
was_disabled = user.subscription.status in (
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
)
|
||||
|
||||
# System-DISABLED subs (insufficient balance) should always be treated as needing resume,
|
||||
|
||||
@@ -48,6 +48,7 @@ class SubscriptionData(BaseModel):
|
||||
hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают)
|
||||
is_active: bool
|
||||
is_expired: bool
|
||||
is_limited: bool = False
|
||||
traffic_purchases: list[TrafficPurchaseInfo] = []
|
||||
# Daily tariff fields
|
||||
is_daily: bool = False
|
||||
|
||||
@@ -54,6 +54,7 @@ class TariffListItem(BaseModel):
|
||||
is_daily: bool = False
|
||||
daily_price_kopeks: int = 0
|
||||
allow_traffic_topup: bool = True
|
||||
show_in_gift: bool = True
|
||||
traffic_limit_gb: int
|
||||
device_limit: int
|
||||
tier_level: int
|
||||
@@ -114,6 +115,8 @@ class TariffDetailResponse(BaseModel):
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = None
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -170,6 +173,8 @@ class TariffCreateRequest(BaseModel):
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool = True
|
||||
|
||||
|
||||
class TariffUpdateRequest(BaseModel):
|
||||
@@ -209,6 +214,8 @@ class TariffUpdateRequest(BaseModel):
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool | None = None
|
||||
|
||||
|
||||
class TariffSortOrderRequest(BaseModel):
|
||||
@@ -243,3 +250,15 @@ class TariffStatsResponse(BaseModel):
|
||||
trial_subscriptions: int
|
||||
revenue_kopeks: int
|
||||
revenue_rubles: float
|
||||
|
||||
|
||||
class SyncSquadsResponse(BaseModel):
|
||||
"""Response after syncing squads for tariff subscriptions."""
|
||||
|
||||
tariff_id: int
|
||||
tariff_name: str
|
||||
total_subscriptions: int
|
||||
updated_count: int
|
||||
failed_count: int
|
||||
skipped_count: int
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
@@ -22,6 +22,7 @@ class SubscriptionStatusEnum(StrEnum):
|
||||
ACTIVE = 'active'
|
||||
EXPIRED = 'expired'
|
||||
DISABLED = 'disabled'
|
||||
LIMITED = 'limited'
|
||||
PENDING = 'pending'
|
||||
|
||||
|
||||
|
||||
@@ -1415,6 +1415,26 @@ class Settings(BaseSettings):
|
||||
return value
|
||||
return None
|
||||
|
||||
_CABINET_URL_DEFAULT = 'https://example.com/cabinet'
|
||||
|
||||
def get_referral_link(self, referral_code: str, bot_username: str | None = None) -> str:
|
||||
"""Build a referral link pointing to the web cabinet.
|
||||
|
||||
Falls back to a Telegram bot deep link when CABINET_URL is not configured.
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
if not referral_code:
|
||||
raise ValueError('referral_code must not be empty or None')
|
||||
|
||||
safe_code = quote(referral_code, safe='')
|
||||
cabinet_url = (self.CABINET_URL or '').strip().rstrip('/')
|
||||
if cabinet_url and cabinet_url != self._CABINET_URL_DEFAULT:
|
||||
sep = '&' if '?' in cabinet_url else '?'
|
||||
return f'{cabinet_url}{sep}ref={safe_code}'
|
||||
username = bot_username or self.get_bot_username() or 'bot'
|
||||
return f'https://t.me/{username}?start={safe_code}'
|
||||
|
||||
def is_deep_links_enabled(self) -> bool:
|
||||
return self.ENABLE_DEEP_LINKS
|
||||
|
||||
@@ -2680,6 +2700,25 @@ PERIOD_PRICES: dict[int, int] = {}
|
||||
refresh_period_prices()
|
||||
|
||||
|
||||
def _build_classic_period_prices() -> dict[int, int]:
|
||||
"""Build classic-mode period prices directly from PRICE_*_DAYS settings.
|
||||
|
||||
Unlike PERIOD_PRICES (which may use DB tariff prices in tariffs mode),
|
||||
this always reflects the env/settings values — the canonical prices for
|
||||
classic (non-tariff) subscriptions.
|
||||
"""
|
||||
return {days: getattr(settings, field_name, 0) for days, field_name in _PERIOD_PRICE_FIELDS.items()}
|
||||
|
||||
|
||||
CLASSIC_PERIOD_PRICES: dict[int, int] = _build_classic_period_prices()
|
||||
|
||||
|
||||
def refresh_classic_period_prices() -> None:
|
||||
"""Rebuild CLASSIC_PERIOD_PRICES from current settings."""
|
||||
CLASSIC_PERIOD_PRICES.clear()
|
||||
CLASSIC_PERIOD_PRICES.update(_build_classic_period_prices())
|
||||
|
||||
|
||||
def get_traffic_prices() -> dict[int, int]:
|
||||
packages = settings.get_traffic_packages()
|
||||
return {package['gb']: package['price'] for package in packages}
|
||||
|
||||
@@ -73,7 +73,12 @@ async def get_cryptobot_payment_by_id_for_update(db: AsyncSession, payment_id: i
|
||||
|
||||
|
||||
async def update_cryptobot_payment_status(
|
||||
db: AsyncSession, invoice_id: str, status: str, paid_at: datetime | None = None
|
||||
db: AsyncSession,
|
||||
invoice_id: str,
|
||||
status: str,
|
||||
paid_at: datetime | None = None,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> CryptoBotPayment | None:
|
||||
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
|
||||
|
||||
@@ -86,8 +91,11 @@ async def update_cryptobot_payment_status(
|
||||
if status == 'paid' and paid_at:
|
||||
payment.paid_at = paid_at
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
logger.info('Обновлен статус CryptoBot платежа', invoice_id=invoice_id, status=status)
|
||||
return payment
|
||||
|
||||
@@ -43,6 +43,8 @@ async def log_promo_offer_action(
|
||||
except Exception:
|
||||
logger.exception('Failed to commit promo offer log entry')
|
||||
raise
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
@@ -306,9 +306,8 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
|
||||
await create_server_squad(
|
||||
db=db,
|
||||
squad_uuid=squad_uuid,
|
||||
display_name=_generate_display_name(original_name),
|
||||
display_name=original_name,
|
||||
original_name=original_name,
|
||||
country_code=_extract_country_code(original_name),
|
||||
price_kopeks=1000,
|
||||
is_available=False,
|
||||
)
|
||||
@@ -482,219 +481,6 @@ async def get_random_trial_squad_uuid(
|
||||
return None
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
"""Генерирует отображаемое название сервера на основе оригинального имени."""
|
||||
|
||||
country_names = {
|
||||
# Европа
|
||||
'NL': '🇳🇱 Нидерланды',
|
||||
'DE': '🇩🇪 Германия',
|
||||
'FR': '🇫🇷 Франция',
|
||||
'GB': '🇬🇧 Великобритания',
|
||||
'UK': '🇬🇧 Великобритания',
|
||||
'IT': '🇮🇹 Италия',
|
||||
'ES': '🇪🇸 Испания',
|
||||
'PT': '🇵🇹 Португалия',
|
||||
'PL': '🇵🇱 Польша',
|
||||
'CZ': '🇨🇿 Чехия',
|
||||
'AT': '🇦🇹 Австрия',
|
||||
'CH': '🇨🇭 Швейцария',
|
||||
'SE': '🇸🇪 Швеция',
|
||||
'NO': '🇳🇴 Норвегия',
|
||||
'FI': '🇫🇮 Финляндия',
|
||||
'DK': '🇩🇰 Дания',
|
||||
'BE': '🇧🇪 Бельгия',
|
||||
'IE': '🇮🇪 Ирландия',
|
||||
'RO': '🇷🇴 Румыния',
|
||||
'BG': '🇧🇬 Болгария',
|
||||
'HU': '🇭🇺 Венгрия',
|
||||
'GR': '🇬🇷 Греция',
|
||||
'LV': '🇱🇻 Латвия',
|
||||
'LT': '🇱🇹 Литва',
|
||||
'EE': '🇪🇪 Эстония',
|
||||
'SK': '🇸🇰 Словакия',
|
||||
'SI': '🇸🇮 Словения',
|
||||
'HR': '🇭🇷 Хорватия',
|
||||
'RS': '🇷🇸 Сербия',
|
||||
'UA': '🇺🇦 Украина',
|
||||
'MD': '🇲🇩 Молдова',
|
||||
'BY': '🇧🇾 Беларусь',
|
||||
'LU': '🇱🇺 Люксембург',
|
||||
# СНГ и Азия
|
||||
'RU': '🇷🇺 Россия',
|
||||
'KZ': '🇰🇿 Казахстан',
|
||||
'UZ': '🇺🇿 Узбекистан',
|
||||
'GE': '🇬🇪 Грузия',
|
||||
'AM': '🇦🇲 Армения',
|
||||
'AZ': '🇦🇿 Азербайджан',
|
||||
# Америка
|
||||
'US': '🇺🇸 США',
|
||||
'CA': '🇨🇦 Канада',
|
||||
'MX': '🇲🇽 Мексика',
|
||||
'BR': '🇧🇷 Бразилия',
|
||||
'AR': '🇦🇷 Аргентина',
|
||||
'CL': '🇨🇱 Чили',
|
||||
'CO': '🇨🇴 Колумбия',
|
||||
# Азия
|
||||
'JP': '🇯🇵 Япония',
|
||||
'KR': '🇰🇷 Южная Корея',
|
||||
'CN': '🇨🇳 Китай',
|
||||
'HK': '🇭🇰 Гонконг',
|
||||
'TW': '🇹🇼 Тайвань',
|
||||
'SG': '🇸🇬 Сингапур',
|
||||
'TH': '🇹🇭 Таиланд',
|
||||
'VN': '🇻🇳 Вьетнам',
|
||||
'MY': '🇲🇾 Малайзия',
|
||||
'ID': '🇮🇩 Индонезия',
|
||||
'PH': '🇵🇭 Филиппины',
|
||||
'IN': '🇮🇳 Индия',
|
||||
'PK': '🇵🇰 Пакистан',
|
||||
# Ближний Восток
|
||||
'IL': '🇮🇱 Израиль',
|
||||
'TR': '🇹🇷 Турция',
|
||||
'AE': '🇦🇪 ОАЭ',
|
||||
'SA': '🇸🇦 Саудовская Аравия',
|
||||
'QA': '🇶🇦 Катар',
|
||||
'BH': '🇧🇭 Бахрейн',
|
||||
'KW': '🇰🇼 Кувейт',
|
||||
# Океания
|
||||
'AU': '🇦🇺 Австралия',
|
||||
'NZ': '🇳🇿 Новая Зеландия',
|
||||
# Африка
|
||||
'ZA': '🇿🇦 ЮАР',
|
||||
'EG': '🇪🇬 Египет',
|
||||
'NG': '🇳🇬 Нигерия',
|
||||
'KE': '🇰🇪 Кения',
|
||||
}
|
||||
|
||||
name_upper = original_name.upper()
|
||||
|
||||
# Сначала ищем код как отдельный элемент (через - или _)
|
||||
for code, display_name in country_names.items():
|
||||
if f'-{code}' in name_upper or f'_{code}' in name_upper:
|
||||
return display_name
|
||||
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
|
||||
return display_name
|
||||
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
|
||||
return display_name
|
||||
if name_upper == code:
|
||||
return display_name
|
||||
|
||||
# Потом ищем просто вхождение кода
|
||||
for code, display_name in country_names.items():
|
||||
if code in name_upper:
|
||||
return display_name
|
||||
|
||||
return f'🌍 {original_name}'
|
||||
|
||||
|
||||
def _extract_country_code(original_name: str) -> str | None:
|
||||
"""Извлекает код страны из оригинального названия."""
|
||||
|
||||
# Полный список кодов стран
|
||||
codes = [
|
||||
# Европа
|
||||
'NL',
|
||||
'DE',
|
||||
'FR',
|
||||
'GB',
|
||||
'UK',
|
||||
'IT',
|
||||
'ES',
|
||||
'PT',
|
||||
'PL',
|
||||
'CZ',
|
||||
'AT',
|
||||
'CH',
|
||||
'SE',
|
||||
'NO',
|
||||
'FI',
|
||||
'DK',
|
||||
'BE',
|
||||
'IE',
|
||||
'RO',
|
||||
'BG',
|
||||
'HU',
|
||||
'GR',
|
||||
'LV',
|
||||
'LT',
|
||||
'EE',
|
||||
'SK',
|
||||
'SI',
|
||||
'HR',
|
||||
'RS',
|
||||
'UA',
|
||||
'MD',
|
||||
'BY',
|
||||
'LU',
|
||||
# СНГ
|
||||
'RU',
|
||||
'KZ',
|
||||
'UZ',
|
||||
'GE',
|
||||
'AM',
|
||||
'AZ',
|
||||
# Америка
|
||||
'US',
|
||||
'CA',
|
||||
'MX',
|
||||
'BR',
|
||||
'AR',
|
||||
'CL',
|
||||
'CO',
|
||||
# Азия
|
||||
'JP',
|
||||
'KR',
|
||||
'CN',
|
||||
'HK',
|
||||
'TW',
|
||||
'SG',
|
||||
'TH',
|
||||
'VN',
|
||||
'MY',
|
||||
'ID',
|
||||
'PH',
|
||||
'IN',
|
||||
'PK',
|
||||
# Ближний Восток
|
||||
'IL',
|
||||
'TR',
|
||||
'AE',
|
||||
'SA',
|
||||
'QA',
|
||||
'BH',
|
||||
'KW',
|
||||
# Океания
|
||||
'AU',
|
||||
'NZ',
|
||||
# Африка
|
||||
'ZA',
|
||||
'EG',
|
||||
'NG',
|
||||
'KE',
|
||||
]
|
||||
|
||||
name_upper = original_name.upper()
|
||||
|
||||
# Сначала ищем код как отдельный элемент
|
||||
for code in codes:
|
||||
if f'-{code}' in name_upper or f'_{code}' in name_upper:
|
||||
return code
|
||||
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
|
||||
return code
|
||||
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
|
||||
return code
|
||||
if name_upper == code:
|
||||
return code
|
||||
|
||||
# Потом просто ищем вхождение
|
||||
for code in codes:
|
||||
if code in name_upper:
|
||||
return code
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
total_result = await db.execute(select(func.count(ServerSquad.id)))
|
||||
total_servers = total_result.scalar()
|
||||
|
||||
@@ -18,7 +18,6 @@ from app.database.models import (
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
UserPromoGroup,
|
||||
UserStatus,
|
||||
)
|
||||
from app.utils.pricing_utils import calculate_months_from_days
|
||||
@@ -38,6 +37,31 @@ def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
|
||||
return elapsed < _WEBHOOK_GUARD_SECONDS
|
||||
|
||||
|
||||
def calc_device_limit_on_tariff_switch(
|
||||
current_device_limit: int | None,
|
||||
old_tariff_device_limit: int | None,
|
||||
new_tariff_device_limit: int | None,
|
||||
max_device_limit: int | None = None,
|
||||
) -> int:
|
||||
"""Calculate device_limit preserving extra purchased devices when switching tariffs.
|
||||
|
||||
Extra devices = current_device_limit - old_tariff_device_limit (clamped to 0).
|
||||
Result = new_tariff_device_limit + extra_devices, capped at max_device_limit.
|
||||
"""
|
||||
old_base = old_tariff_device_limit if old_tariff_device_limit is not None else 0
|
||||
current = current_device_limit if current_device_limit is not None else old_base
|
||||
extra = max(0, current - old_base)
|
||||
|
||||
new_base = new_tariff_device_limit if new_tariff_device_limit is not None else 1
|
||||
total = new_base + extra
|
||||
|
||||
effective_max = max_device_limit or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
if effective_max and total > effective_max:
|
||||
total = effective_max
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def is_active_paid_subscription(subscription: Subscription | None) -> bool:
|
||||
"""Return True if subscription is active, paid (non-trial), and not expired."""
|
||||
if not subscription:
|
||||
@@ -357,6 +381,7 @@ async def extend_subscription(
|
||||
traffic_limit_gb: int | None = None,
|
||||
device_limit: int | None = None,
|
||||
connected_squads: list[str] | None = None,
|
||||
commit: bool = True,
|
||||
) -> Subscription:
|
||||
"""Продлевает подписку на указанное количество дней.
|
||||
|
||||
@@ -389,6 +414,7 @@ async def extend_subscription(
|
||||
was_expired = subscription.status in (
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
) or (subscription.end_date is not None and subscription.end_date <= current_time)
|
||||
|
||||
if is_tariff_change:
|
||||
@@ -445,6 +471,7 @@ async def extend_subscription(
|
||||
if days > 0 and subscription.status in (
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
):
|
||||
previous_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
@@ -567,9 +594,13 @@ async def extend_subscription(
|
||||
|
||||
subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
await clear_notifications(db, subscription.id)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
await clear_notifications(db, subscription.id, commit=commit)
|
||||
|
||||
logger.info('✅ Подписка продлена до', end_date=subscription.end_date)
|
||||
logger.info('📊 Новые параметры: статус=, окончание', status=subscription.status, end_date=subscription.end_date)
|
||||
@@ -772,26 +803,39 @@ async def deactivate_subscription(db: AsyncSession, subscription: Subscription)
|
||||
|
||||
|
||||
async def reactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
|
||||
"""Реактивация подписки (например, после повторной подписки на канал).
|
||||
"""Реактивация подписки (например, после повторной подписки на канал или докупки трафика).
|
||||
|
||||
Активирует только если подписка была DISABLED и ещё не истекла.
|
||||
Активирует если подписка была DISABLED или EXPIRED и ещё не истекла по времени.
|
||||
Не логирует если реактивация не требуется.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Тихо выходим если реактивация не нужна
|
||||
if subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
# Тихо выходим если реактивация не нужна (уже активна или другой статус)
|
||||
reactivatable_statuses = {
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
}
|
||||
if subscription.status not in reactivatable_statuses:
|
||||
return subscription
|
||||
|
||||
if subscription.end_date and subscription.end_date <= now:
|
||||
if not subscription.end_date or subscription.end_date <= now:
|
||||
return subscription
|
||||
|
||||
old_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = now
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
'✅ Подписка реактивирована',
|
||||
subscription_id=subscription.id,
|
||||
user_id=subscription.user_id,
|
||||
old_status=old_status,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -1367,32 +1411,6 @@ async def get_subscription_server_ids(db: AsyncSession, subscription_id: int) ->
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def get_subscription_servers(db: AsyncSession, subscription_id: int) -> list[dict]:
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(
|
||||
select(SubscriptionServer, ServerSquad)
|
||||
.join(ServerSquad, SubscriptionServer.server_squad_id == ServerSquad.id)
|
||||
.where(SubscriptionServer.subscription_id == subscription_id)
|
||||
)
|
||||
|
||||
servers_info = []
|
||||
for sub_server, server_squad in result.fetchall():
|
||||
servers_info.append(
|
||||
{
|
||||
'server_id': server_squad.id,
|
||||
'squad_uuid': server_squad.squad_uuid,
|
||||
'display_name': server_squad.display_name,
|
||||
'country_code': server_squad.country_code,
|
||||
'paid_price_kopeks': sub_server.paid_price_kopeks,
|
||||
'connected_at': sub_server.connected_at,
|
||||
'is_available': server_squad.is_available,
|
||||
}
|
||||
)
|
||||
|
||||
return servers_info
|
||||
|
||||
|
||||
async def remove_subscription_servers(db: AsyncSession, subscription_id: int, server_squad_ids: list[int]) -> bool:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
@@ -1416,232 +1434,6 @@ async def remove_subscription_servers(db: AsyncSession, subscription_id: int, se
|
||||
return False
|
||||
|
||||
|
||||
async def get_subscription_renewal_cost(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
period_days: int,
|
||||
*,
|
||||
user: User | None = None,
|
||||
promo_group: PromoGroup | None = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user)
|
||||
.selectinload(User.user_promo_groups)
|
||||
.selectinload(UserPromoGroup.promo_group),
|
||||
)
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
if not subscription:
|
||||
return base_price
|
||||
|
||||
if user is None:
|
||||
user = subscription.user
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_info = await get_subscription_servers(db, subscription_id)
|
||||
servers_price_per_month = 0
|
||||
for server_info in servers_info:
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks).where(ServerSquad.id == server_info['server_id'])
|
||||
)
|
||||
current_server_price = result.scalar() or 0
|
||||
servers_price_per_month += current_server_price
|
||||
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'servers',
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_cost = discounted_servers_per_month * months_in_period
|
||||
total_servers_discount = servers_discount_per_month * months_in_period
|
||||
|
||||
# В режиме fixed_with_topup при продлении используем фиксированный лимит
|
||||
purchased_traffic = subscription.purchased_traffic_gb or 0
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit())
|
||||
# Separate base traffic from purchased to avoid wrong tier lookup
|
||||
elif purchased_traffic > 0:
|
||||
base_traffic_gb = (subscription.traffic_limit_gb or 0) - purchased_traffic
|
||||
if base_traffic_gb <= 0:
|
||||
logger.warning(
|
||||
'Purchased traffic >= total limit, pricing purchased portion only',
|
||||
subscription_id=subscription.id,
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
purchased_traffic_gb=purchased_traffic,
|
||||
)
|
||||
traffic_price_per_month = settings.get_traffic_price(purchased_traffic)
|
||||
else:
|
||||
traffic_price_per_month = settings.get_traffic_price(base_traffic_gb) + settings.get_traffic_price(
|
||||
purchased_traffic
|
||||
)
|
||||
else:
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'traffic',
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_cost = discounted_traffic_per_month * months_in_period
|
||||
total_traffic_discount = traffic_discount_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'devices',
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_cost = discounted_devices_per_month * months_in_period
|
||||
total_devices_discount = devices_discount_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
|
||||
|
||||
logger.info(
|
||||
'💰 Расчет продления подписки на дней ( мес)',
|
||||
subscription_id=subscription_id,
|
||||
period_days=period_days,
|
||||
months_in_period=months_in_period,
|
||||
)
|
||||
logger.info('📅 Период: ₽', base_price=base_price / 100)
|
||||
if total_servers_cost > 0:
|
||||
message = f' 🌍 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_cost / 100}₽'
|
||||
if total_servers_discount > 0:
|
||||
message += f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)'
|
||||
logger.info(message)
|
||||
if total_traffic_cost > 0:
|
||||
message = (
|
||||
f' 📊 Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_cost / 100}₽'
|
||||
)
|
||||
if total_traffic_discount > 0:
|
||||
message += f' (скидка {traffic_discount_percent}%: -{total_traffic_discount / 100}₽)'
|
||||
logger.info(message)
|
||||
if total_devices_cost > 0:
|
||||
message = f' 📱 Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period} = {total_devices_cost / 100}₽'
|
||||
if total_devices_discount > 0:
|
||||
message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)'
|
||||
logger.info(message)
|
||||
logger.info('💎 ИТОГО: ₽', total_cost=total_cost / 100)
|
||||
|
||||
return total_cost
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка расчета стоимости продления', error=e)
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
return PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
|
||||
async def calculate_addon_cost_for_remaining_period(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
additional_traffic_gb: int = 0,
|
||||
additional_devices: int = 0,
|
||||
additional_server_ids: list[int] = None,
|
||||
*,
|
||||
user: User | None = None,
|
||||
promo_group: PromoGroup | None = None,
|
||||
) -> int:
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
now = datetime.now(UTC)
|
||||
days_to_pay = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_to_pay
|
||||
|
||||
total_cost = 0
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, 'user', None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'traffic',
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
traffic_total_cost = int(discounted_traffic_per_month * days_to_pay / 30)
|
||||
total_cost += traffic_total_cost
|
||||
message = f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес × {days_to_pay} дн. = {traffic_total_cost / 100}₽'
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f' (скидка {traffic_discount_percent}%: -{int(traffic_discount_per_month * days_to_pay / 30) / 100}₽)'
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'devices',
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
devices_total_cost = int(discounted_devices_per_month * days_to_pay / 30)
|
||||
total_cost += devices_total_cost
|
||||
message = f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес × {days_to_pay} дн. = {devices_total_cost / 100}₽'
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f' (скидка {devices_discount_percent}%: -{int(devices_discount_per_month * days_to_pay / 30) / 100}₽)'
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_server_ids:
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
for server_id in additional_server_ids:
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks, ServerSquad.display_name).where(ServerSquad.id == server_id)
|
||||
)
|
||||
server_data = result.first()
|
||||
if server_data:
|
||||
server_price_per_month, server_name = server_data
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'servers',
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
|
||||
discounted_server_per_month = server_price_per_month - server_discount_per_month
|
||||
server_total_cost = int(discounted_server_per_month * days_to_pay / 30)
|
||||
total_cost += server_total_cost
|
||||
message = f'Сервер {server_name}: {server_price_per_month / 100}₽/мес × {days_to_pay} дн. = {server_total_cost / 100}₽'
|
||||
if server_discount_per_month > 0:
|
||||
message += f' (скидка {servers_discount_percent}%: -{int(server_discount_per_month * days_to_pay / 30) / 100}₽)'
|
||||
logger.info(message)
|
||||
|
||||
logger.info('💰 Итого доплата за дн.: ₽', days_to_pay=days_to_pay, total_cost=total_cost / 100)
|
||||
return total_cost
|
||||
|
||||
|
||||
async def expire_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
@@ -2206,8 +1998,12 @@ async def resume_daily_subscription(
|
||||
|
||||
subscription.is_daily_paused = False
|
||||
|
||||
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED
|
||||
if subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
|
||||
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED/LIMITED
|
||||
if subscription.status in (
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
):
|
||||
previous_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
# Обновляем время последнего списания для корректного расчёта следующего
|
||||
|
||||
@@ -185,6 +185,8 @@ async def create_tariff(
|
||||
traffic_price_per_gb_kopeks: int = 0,
|
||||
min_traffic_gb: int = 1,
|
||||
max_traffic_gb: int = 1000,
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift: bool = True,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = None, # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
@@ -223,6 +225,8 @@ async def create_tariff(
|
||||
traffic_price_per_gb_kopeks=max(0, traffic_price_per_gb_kopeks),
|
||||
min_traffic_gb=max(1, min_traffic_gb),
|
||||
max_traffic_gb=max(1, max_traffic_gb),
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift=show_in_gift,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode=traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
@@ -290,6 +294,8 @@ async def update_tariff(
|
||||
traffic_price_per_gb_kopeks: int | None = None,
|
||||
min_traffic_gb: int | None = None,
|
||||
max_traffic_gb: int | None = None,
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift: bool | None = None,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = ..., # ... = не передан, None = сбросить к глобальной настройке
|
||||
# Внешний сквад RemnaWave
|
||||
@@ -354,6 +360,9 @@ async def update_tariff(
|
||||
tariff.min_traffic_gb = max(1, min_traffic_gb)
|
||||
if max_traffic_gb is not None:
|
||||
tariff.max_traffic_gb = max(1, max_traffic_gb)
|
||||
# Видимость в разделе подарков
|
||||
if show_in_gift is not None:
|
||||
tariff.show_in_gift = show_in_gift
|
||||
# Режим сброса трафика
|
||||
if traffic_reset_mode is not ...:
|
||||
tariff.traffic_reset_mode = traffic_reset_mode
|
||||
|
||||
+49
-19
@@ -410,6 +410,18 @@ async def update_user(db: AsyncSession, user: User, **kwargs) -> User:
|
||||
return user
|
||||
|
||||
|
||||
async def lock_user_for_update(db: AsyncSession, user: User) -> User:
|
||||
"""Lock user row with SELECT FOR UPDATE to prevent concurrent balance modifications.
|
||||
|
||||
Returns the refreshed user object with current DB values.
|
||||
Must be called within an active transaction before modifying balance_kopeks.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def add_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
@@ -421,6 +433,12 @@ async def add_user_balance(
|
||||
payment_method: PaymentMethod | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
# Lock the user row to prevent concurrent balance race conditions
|
||||
locked_result = await db.execute(
|
||||
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
|
||||
)
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
@@ -501,13 +519,19 @@ async def subtract_user_balance(
|
||||
transaction_type: TransactionType = TransactionType.WITHDRAWAL,
|
||||
consume_promo_offer: bool = False,
|
||||
mark_as_paid_subscription: bool = False,
|
||||
commit: bool = True,
|
||||
) -> bool:
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.info('💸 ОТЛАДКА subtract_user_balance:')
|
||||
logger.info('👤 User ID: (ID: )', user_id=user.id, user_id_display=user_id_display)
|
||||
logger.info('💰 Баланс до списания: копеек', balance_kopeks=user.balance_kopeks)
|
||||
logger.info('💸 Сумма к списанию: копеек', amount_kopeks=amount_kopeks)
|
||||
logger.info('📝 Описание', description=description)
|
||||
if amount_kopeks < 0:
|
||||
logger.error('subtract_user_balance called with negative amount', amount_kopeks=amount_kopeks, user_id=user.id)
|
||||
return False
|
||||
|
||||
logger.debug(
|
||||
'subtract_user_balance called',
|
||||
user_id=user.id,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
)
|
||||
|
||||
# Lock the user row to prevent concurrent balance race conditions
|
||||
locked_result = await db.execute(
|
||||
@@ -572,8 +596,6 @@ async def subtract_user_balance(
|
||||
create_transaction as create_trans,
|
||||
)
|
||||
|
||||
# create_trans commits the session, atomically persisting
|
||||
# both the balance change and the transaction record
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
@@ -581,11 +603,15 @@ async def subtract_user_balance(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=payment_method,
|
||||
commit=commit,
|
||||
)
|
||||
else:
|
||||
elif commit:
|
||||
await db.commit()
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
await db.refresh(user)
|
||||
if commit:
|
||||
await db.refresh(user)
|
||||
|
||||
if consume_promo_offer and log_context:
|
||||
try:
|
||||
@@ -598,26 +624,30 @@ async def subtract_user_balance(
|
||||
percent=log_context.get('percent'),
|
||||
effect_type=log_context.get('effect_type'),
|
||||
details=log_context.get('details'),
|
||||
commit=commit,
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to record promo offer consumption log for user', user_id=user.id, log_error=log_error
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to rollback session after promo offer consumption log failure',
|
||||
rollback_error=rollback_error,
|
||||
)
|
||||
if commit:
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to rollback session after promo offer consumption log failure',
|
||||
rollback_error=rollback_error,
|
||||
)
|
||||
|
||||
logger.info('✅ Средства списаны: →', old_balance=old_balance, balance_kopeks=user.balance_kopeks)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error('❌ ОШИБКА СПИСАНИЯ', error=e)
|
||||
await db.rollback()
|
||||
return False
|
||||
if commit:
|
||||
await db.rollback()
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
|
||||
|
||||
@@ -40,6 +40,7 @@ async def _sync_user_primary_promo_group(
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка синхронизации primary промогруппы пользователя', user_id=user_id, error=error)
|
||||
raise
|
||||
|
||||
|
||||
async def sync_user_primary_promo_group(
|
||||
|
||||
+15
-2
@@ -123,6 +123,7 @@ class SubscriptionStatus(Enum):
|
||||
ACTIVE = 'active'
|
||||
EXPIRED = 'expired'
|
||||
DISABLED = 'disabled'
|
||||
LIMITED = 'limited'
|
||||
PENDING = 'pending'
|
||||
|
||||
|
||||
@@ -974,6 +975,9 @@ class Tariff(Base):
|
||||
min_traffic_gb = Column(Integer, default=1, nullable=False) # Минимальный трафик в ГБ
|
||||
max_traffic_gb = Column(Integer, default=1000, nullable=False) # Максимальный трафик в ГБ
|
||||
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift = Column(Boolean, default=True, server_default='true', nullable=False)
|
||||
|
||||
# Режим сброса трафика: DAY, WEEK, MONTH, NO_RESET (по умолчанию берётся из конфига)
|
||||
traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку
|
||||
|
||||
@@ -1144,7 +1148,9 @@ class User(Base):
|
||||
discord_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
vk_id = Column(BigInteger, unique=True, nullable=True, index=True)
|
||||
broadcasts = relationship('BroadcastHistory', back_populates='admin')
|
||||
referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id')
|
||||
referrals = relationship(
|
||||
'User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id', post_update=True
|
||||
)
|
||||
subscription = relationship('Subscription', back_populates='user', uselist=False)
|
||||
transactions = relationship('Transaction', back_populates='user')
|
||||
referral_earnings = relationship('ReferralEarning', foreign_keys='ReferralEarning.user_id', back_populates='user')
|
||||
@@ -1345,6 +1351,9 @@ class Subscription(Base):
|
||||
if self.status == SubscriptionStatus.DISABLED.value:
|
||||
return 'disabled'
|
||||
|
||||
if self.status == SubscriptionStatus.LIMITED.value:
|
||||
return 'limited'
|
||||
|
||||
if self.status == SubscriptionStatus.ACTIVE.value:
|
||||
if end is None or end <= current_time:
|
||||
return 'expired'
|
||||
@@ -1369,6 +1378,8 @@ class Subscription(Base):
|
||||
return '🟢 Активна'
|
||||
if actual_status == 'disabled':
|
||||
return '⚫ Отключена'
|
||||
if actual_status == 'limited':
|
||||
return '⚠️ Трафик исчерпан'
|
||||
if actual_status == 'trial':
|
||||
return '🎯 Тестовая'
|
||||
|
||||
@@ -1386,6 +1397,8 @@ class Subscription(Base):
|
||||
return '💎'
|
||||
if actual_status == 'disabled':
|
||||
return '⚫'
|
||||
if actual_status == 'limited':
|
||||
return '⚠️'
|
||||
if actual_status == 'trial':
|
||||
return '🎁'
|
||||
|
||||
@@ -1434,7 +1447,7 @@ class Subscription(Base):
|
||||
else:
|
||||
self.end_date = datetime.now(UTC) + timedelta(days=days)
|
||||
|
||||
if self.status == SubscriptionStatus.EXPIRED.value:
|
||||
if self.status in (SubscriptionStatus.EXPIRED.value, SubscriptionStatus.LIMITED.value):
|
||||
self.status = SubscriptionStatus.ACTIVE.value
|
||||
|
||||
def add_traffic(self, gb: int):
|
||||
|
||||
Vendored
+6
-1
@@ -400,7 +400,12 @@ class RemnaWaveAPI:
|
||||
|
||||
if response.status >= 400:
|
||||
error_message = response_data.get('message', f'HTTP {response.status}')
|
||||
log = logger.warning if response.status in (502, 503, 504) else logger.error
|
||||
# Downgrade known-harmless 400s to warning (caller handles them as success)
|
||||
error_lower = str(error_message).lower()
|
||||
is_harmless = response.status == 400 and (
|
||||
'already enabled' in error_lower or 'already disabled' in error_lower
|
||||
)
|
||||
log = logger.warning if response.status in (502, 503, 504) or is_harmless else logger.error
|
||||
log('API Error %s: %s', response.status, error_message)
|
||||
log('Response: %s', response_text[:500])
|
||||
raise RemnaWaveAPIError(error_message, response.status, response_data)
|
||||
|
||||
@@ -1557,7 +1557,11 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
if target == 'expired':
|
||||
# Истекшие подписки
|
||||
now = datetime.now(UTC)
|
||||
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
|
||||
expired_statuses = [
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
]
|
||||
query = (
|
||||
select(sql_func.count(distinct(User.id)))
|
||||
.outerjoin(Subscription, User.id == Subscription.user_id)
|
||||
@@ -1576,7 +1580,11 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
if target == 'expired_subscribers':
|
||||
# То же что и expired
|
||||
now = datetime.now(UTC)
|
||||
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
|
||||
expired_statuses = [
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
]
|
||||
query = (
|
||||
select(sql_func.count(distinct(User.id)))
|
||||
.outerjoin(Subscription, User.id == Subscription.user_id)
|
||||
|
||||
@@ -977,7 +977,7 @@ async def _render_squad_selection(
|
||||
if not selected_server:
|
||||
selected_server = await get_server_squad_by_uuid(db, selected_uuid)
|
||||
if selected_server:
|
||||
selected_server_name = selected_server.display_name
|
||||
selected_server_name = html.escape(selected_server.display_name)
|
||||
|
||||
header = texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE', '🌍 <b>Выберите сквад</b>')
|
||||
if selected_server_name:
|
||||
|
||||
@@ -629,6 +629,9 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d
|
||||
db.add(earning)
|
||||
|
||||
# Добавляем на баланс пользователя
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
target_user = await lock_user_for_update(db, target_user)
|
||||
target_user.balance_kopeks += amount_kopeks
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import math
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -175,7 +176,7 @@ def _format_migration_server_label(texts, server) -> str:
|
||||
return texts.t(
|
||||
'ADMIN_SQUAD_MIGRATION_SERVER_LABEL',
|
||||
'{name} — 👥 {users} ({status})',
|
||||
).format(name=server.display_name, users=server.current_users, status=status)
|
||||
).format(name=html.escape(server.display_name), users=server.current_users, status=status)
|
||||
|
||||
|
||||
def _build_migration_keyboard(
|
||||
|
||||
@@ -44,8 +44,8 @@ def _build_server_edit_view(server):
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Название: {html.escape(server.display_name)}
|
||||
• Оригинальное: {html.escape(server.original_name) if server.original_name else 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
@@ -172,7 +172,7 @@ async def show_servers_list(callback: types.CallbackQuery, db_user: User, db: As
|
||||
status_emoji = '✅' if server.is_available else '❌'
|
||||
price_text = f'{int(server.price_rubles)} ₽' if server.price_kopeks > 0 else 'Бесплатно'
|
||||
|
||||
text += f'{i}. {status_emoji} {server.display_name}\n'
|
||||
text += f'{i}. {status_emoji} {html.escape(server.display_name)}\n'
|
||||
text += f' 💰 Цена: {price_text}'
|
||||
|
||||
if server.max_users:
|
||||
@@ -559,7 +559,7 @@ async def start_server_edit_name(callback: types.CallbackQuery, state: FSMContex
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✏️ <b>Редактирование названия</b>\n\n'
|
||||
f'Текущее название: <b>{server.display_name}</b>\n\n'
|
||||
f'Текущее название: <b>{html.escape(server.display_name)}</b>\n\n'
|
||||
f'Отправьте новое название для сервера:',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -621,7 +621,7 @@ async def delete_server_confirm(callback: types.CallbackQuery, db_user: User, db
|
||||
🗑️ <b>Удаление сервера</b>
|
||||
|
||||
Вы действительно хотите удалить сервер:
|
||||
<b>{server.display_name}</b>
|
||||
<b>{html.escape(server.display_name)}</b>
|
||||
|
||||
⚠️ <b>Внимание!</b>
|
||||
Сервер можно удалить только если к нему нет активных подключений.
|
||||
@@ -658,7 +658,7 @@ async def delete_server_execute(callback: types.CallbackQuery, db_user: User, db
|
||||
await cache.delete_pattern('available_countries*')
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ Сервер <b>{server.display_name}</b> успешно удален!',
|
||||
f'✅ Сервер <b>{html.escape(server.display_name)}</b> успешно удален!',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='📋 К списку серверов', callback_data='admin_servers_list')]
|
||||
@@ -668,7 +668,7 @@ async def delete_server_execute(callback: types.CallbackQuery, db_user: User, db
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f'❌ Не удалось удалить сервер <b>{server.display_name}</b>\n\nВозможно, к нему есть активные подключения.',
|
||||
f'❌ Не удалось удалить сервер <b>{html.escape(server.display_name)}</b>\n\nВозможно, к нему есть активные подключения.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='🔙 К серверу', callback_data=f'admin_server_edit_{server_id}')]
|
||||
@@ -706,7 +706,7 @@ async def show_server_detailed_stats(callback: types.CallbackQuery, db_user: Use
|
||||
|
||||
for i, server in enumerate(sorted_servers[:5], 1):
|
||||
price_text = f'{int(server.price_rubles)} ₽' if server.price_kopeks > 0 else 'Бесплатно'
|
||||
text += f'{i}. {server.display_name} - {price_text}\n'
|
||||
text += f'{i}. {html.escape(server.display_name)} - {price_text}\n'
|
||||
|
||||
if not sorted_servers:
|
||||
text += 'Нет доступных серверов\n'
|
||||
@@ -968,7 +968,7 @@ async def start_server_edit_promo_groups(
|
||||
|
||||
text = (
|
||||
'🎯 <b>Настройка промогрупп</b>\n\n'
|
||||
f'Сервер: <b>{server.display_name}</b>\n\n'
|
||||
f'Сервер: <b>{html.escape(server.display_name)}</b>\n\n'
|
||||
'Выберите промогруппы, которым будет доступен этот сервер.\n'
|
||||
'Должна быть выбрана минимум одна промогруппа.'
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.database.models import Tariff, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatting import format_period, format_price_kopeks, format_traffic
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -29,34 +30,6 @@ logger = structlog.get_logger(__name__)
|
||||
ITEMS_PER_PAGE = 10
|
||||
|
||||
|
||||
def _format_traffic(gb: int) -> str:
|
||||
"""Форматирует трафик."""
|
||||
if gb == 0:
|
||||
return 'Безлимит'
|
||||
return f'{gb} ГБ'
|
||||
|
||||
|
||||
def _format_price_kopeks(kopeks: int) -> str:
|
||||
"""Форматирует цену из копеек в рубли."""
|
||||
rubles = kopeks / 100
|
||||
if rubles == int(rubles):
|
||||
return f'{int(rubles)} ₽'
|
||||
return f'{rubles:.2f} ₽'
|
||||
|
||||
|
||||
def _format_period(days: int) -> str:
|
||||
"""Форматирует период."""
|
||||
if days == 1:
|
||||
return '1 день'
|
||||
if days < 5:
|
||||
return f'{days} дня'
|
||||
if days < 21 or days % 10 >= 5 or days % 10 == 0:
|
||||
return f'{days} дней'
|
||||
if days % 10 == 1:
|
||||
return f'{days} день'
|
||||
return f'{days} дня'
|
||||
|
||||
|
||||
def _parse_period_prices(text: str) -> dict[str, int]:
|
||||
"""
|
||||
Парсит строку с ценами периодов.
|
||||
@@ -94,7 +67,7 @@ def _format_period_prices_display(prices: dict[str, int]) -> str:
|
||||
for period_str in sorted(prices.keys(), key=int):
|
||||
period = int(period_str)
|
||||
price = prices[period_str]
|
||||
lines.append(f' • {_format_period(period)}: {_format_price_kopeks(price)}')
|
||||
lines.append(f' • {format_period(period)}: {format_price_kopeks(price)}')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
@@ -278,7 +251,7 @@ def _format_traffic_topup_packages(tariff: Tariff) -> str:
|
||||
lines = ['✅ Включено']
|
||||
for gb in sorted(packages.keys()):
|
||||
price = packages[gb]
|
||||
lines.append(f' • {gb} ГБ: {_format_price_kopeks(price)}')
|
||||
lines.append(f' • {gb} ГБ: {format_price_kopeks(price)}')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
@@ -288,7 +261,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
|
||||
get_texts(language)
|
||||
|
||||
status = '✅ Активен' if tariff.is_active else '❌ Неактивен'
|
||||
traffic = _format_traffic(tariff.traffic_limit_gb)
|
||||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||||
prices_display = _format_period_prices_display(tariff.period_prices or {})
|
||||
|
||||
# Форматируем список серверов
|
||||
@@ -314,7 +287,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
|
||||
# Форматируем цену за устройство
|
||||
device_price = getattr(tariff, 'device_price_kopeks', None)
|
||||
if device_price is not None and device_price > 0:
|
||||
device_price_display = _format_price_kopeks(device_price) + '/мес'
|
||||
device_price_display = format_price_kopeks(device_price) + '/мес'
|
||||
else:
|
||||
device_price_display = 'Недоступно'
|
||||
|
||||
@@ -338,7 +311,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
|
||||
|
||||
# Формируем блок цен в зависимости от типа тарифа
|
||||
if is_daily:
|
||||
price_block = f'<b>💰 Суточная цена:</b> {_format_price_kopeks(daily_price_kopeks)}/день'
|
||||
price_block = f'<b>💰 Суточная цена:</b> {format_price_kopeks(daily_price_kopeks)}/день'
|
||||
tariff_type = '🔄 Суточный'
|
||||
else:
|
||||
price_block = f'<b>Цены:</b>\n{prices_display}'
|
||||
@@ -619,7 +592,7 @@ async def start_edit_daily_price(
|
||||
await callback.message.edit_text(
|
||||
f'💰 <b>Редактирование суточной цены</b>\n\n'
|
||||
f'Тариф: {tariff.name}\n'
|
||||
f'Текущая цена: {_format_price_kopeks(current_price)}/день\n\n'
|
||||
f'Текущая цена: {format_price_kopeks(current_price)}/день\n\n'
|
||||
'Введите новую цену за день в рублях.\n'
|
||||
'Пример: <code>50</code> или <code>99.90</code>',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
@@ -698,7 +671,7 @@ async def process_daily_price_input(
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
|
||||
|
||||
await message.answer(
|
||||
f'✅ Суточная цена установлена: {_format_price_kopeks(price_kopeks)}/день\n\n'
|
||||
f'✅ Суточная цена установлена: {format_price_kopeks(price_kopeks)}/день\n\n'
|
||||
+ format_tariff_info(tariff, db_user.language, subs_count),
|
||||
reply_markup=get_tariff_view_keyboard(tariff, db_user.language),
|
||||
parse_mode='HTML',
|
||||
@@ -793,7 +766,7 @@ async def process_tariff_traffic(
|
||||
await state.update_data(tariff_traffic=traffic)
|
||||
await state.set_state(AdminStates.creating_tariff_devices)
|
||||
|
||||
traffic_display = _format_traffic(traffic)
|
||||
traffic_display = format_traffic(traffic)
|
||||
|
||||
await message.answer(
|
||||
'📦 <b>Создание тарифа</b>\n\n'
|
||||
@@ -831,7 +804,7 @@ async def process_tariff_devices(
|
||||
await state.update_data(tariff_devices=devices)
|
||||
await state.set_state(AdminStates.creating_tariff_tier)
|
||||
|
||||
traffic_display = _format_traffic(data['tariff_traffic'])
|
||||
traffic_display = format_traffic(data['tariff_traffic'])
|
||||
|
||||
await message.answer(
|
||||
'📦 <b>Создание тарифа</b>\n\n'
|
||||
@@ -871,7 +844,7 @@ async def process_tariff_tier(
|
||||
data = await state.get_data()
|
||||
await state.update_data(tariff_tier=tier)
|
||||
|
||||
traffic_display = _format_traffic(data['tariff_traffic'])
|
||||
traffic_display = format_traffic(data['tariff_traffic'])
|
||||
|
||||
# Шаг 5/6: Выбор типа тарифа
|
||||
await message.answer(
|
||||
@@ -907,7 +880,7 @@ async def select_tariff_type_periodic(
|
||||
await state.update_data(tariff_is_daily=False)
|
||||
await state.set_state(AdminStates.creating_tariff_prices)
|
||||
|
||||
traffic_display = _format_traffic(data['tariff_traffic'])
|
||||
traffic_display = format_traffic(data['tariff_traffic'])
|
||||
|
||||
await callback.message.edit_text(
|
||||
'📦 <b>Создание тарифа</b>\n\n'
|
||||
@@ -945,7 +918,7 @@ async def select_tariff_type_daily(
|
||||
await state.update_data(tariff_is_daily=True)
|
||||
await state.set_state(AdminStates.editing_tariff_daily_price)
|
||||
|
||||
traffic_display = _format_traffic(data['tariff_traffic'])
|
||||
traffic_display = format_traffic(data['tariff_traffic'])
|
||||
|
||||
await callback.message.edit_text(
|
||||
'📦 <b>Создание суточного тарифа</b>\n\n'
|
||||
@@ -989,7 +962,7 @@ async def process_tariff_prices(
|
||||
data = await state.get_data()
|
||||
await state.update_data(tariff_prices=prices)
|
||||
|
||||
_format_traffic(data['tariff_traffic'])
|
||||
format_traffic(data['tariff_traffic'])
|
||||
_format_period_prices_display(prices)
|
||||
|
||||
# Создаем тариф
|
||||
@@ -1170,7 +1143,7 @@ async def start_edit_tariff_traffic(
|
||||
await state.set_state(AdminStates.editing_tariff_traffic)
|
||||
await state.update_data(tariff_id=tariff_id, language=db_user.language)
|
||||
|
||||
current_traffic = _format_traffic(tariff.traffic_limit_gb)
|
||||
current_traffic = format_traffic(tariff.traffic_limit_gb)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'📊 <b>Редактирование трафика</b>\n\n'
|
||||
@@ -1462,7 +1435,7 @@ async def start_edit_tariff_device_price(
|
||||
|
||||
device_price = getattr(tariff, 'device_price_kopeks', None)
|
||||
if device_price is not None and device_price > 0:
|
||||
current_price = _format_price_kopeks(device_price) + '/мес'
|
||||
current_price = format_price_kopeks(device_price) + '/мес'
|
||||
else:
|
||||
current_price = 'Недоступно (докупка устройств запрещена)'
|
||||
|
||||
@@ -1782,7 +1755,7 @@ async def start_edit_tariff_traffic_topup(
|
||||
status = '✅ Включено'
|
||||
if packages:
|
||||
packages_display = '\n'.join(
|
||||
f' • {gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
f' • {gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
)
|
||||
else:
|
||||
packages_display = ' Пакеты не настроены'
|
||||
@@ -1871,7 +1844,7 @@ async def toggle_tariff_traffic_topup(
|
||||
status = '✅ Включено'
|
||||
if packages:
|
||||
packages_display = '\n'.join(
|
||||
f' • {gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
f' • {gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
)
|
||||
else:
|
||||
packages_display = ' Пакеты не настроены'
|
||||
@@ -1951,7 +1924,7 @@ async def start_edit_traffic_topup_packages(
|
||||
|
||||
if packages:
|
||||
packages_display = '\n'.join(
|
||||
f' • {gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
f' • {gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
)
|
||||
else:
|
||||
packages_display = ' Не настроены'
|
||||
@@ -2020,9 +1993,7 @@ async def process_edit_traffic_topup_packages(
|
||||
|
||||
# Показываем обновленное меню
|
||||
texts = get_texts(db_user.language)
|
||||
packages_display = '\n'.join(
|
||||
f' • {gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
)
|
||||
packages_display = '\n'.join(f' • {gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items()))
|
||||
max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
|
||||
max_limit_display = f'{max_topup_traffic} ГБ' if max_topup_traffic > 0 else 'Без ограничений'
|
||||
|
||||
@@ -2136,7 +2107,7 @@ async def process_edit_max_topup_traffic(
|
||||
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
|
||||
if packages:
|
||||
packages_display = '\n'.join(
|
||||
f' • {gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
f' • {gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
)
|
||||
else:
|
||||
packages_display = ' Пакеты не настроены'
|
||||
|
||||
+37
-14
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -868,7 +869,7 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
|
||||
try:
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if server:
|
||||
text += f'• {server.display_name}\n'
|
||||
text += f'• {html.escape(server.display_name)}\n'
|
||||
else:
|
||||
text += f'• {squad_uuid[:8]}... (неизвестный)\n'
|
||||
except Exception as e:
|
||||
@@ -1209,7 +1210,7 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
|
||||
end_date=format_datetime(subscription.end_date),
|
||||
traffic=traffic_usage,
|
||||
devices=subscription.device_limit,
|
||||
countries=len(subscription.connected_squads),
|
||||
countries=len(subscription.connected_squads or []),
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -2711,7 +2712,7 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db:
|
||||
text += f'• Статус: {sub_status}{sub_type}\n'
|
||||
text += f'• Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ\n'
|
||||
text += f'• Устройства: {subscription.device_limit}\n'
|
||||
text += f'• Стран: {len(subscription.connected_squads)}\n'
|
||||
text += f'• Стран: {len(subscription.connected_squads or [])}\n'
|
||||
else:
|
||||
text += '• Отсутствует\n'
|
||||
|
||||
@@ -4002,12 +4003,20 @@ async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, adm
|
||||
else:
|
||||
await add_subscription_traffic(db, subscription, gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if subscription.status == 'active':
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user and user.remnawave_uuid:
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
traffic_text = 'безлимитный' if gb == 0 else f'{gb} ГБ'
|
||||
logger.info('Админ добавил трафик пользователю', admin_id=admin_id, traffic_text=traffic_text, user_id=user_id)
|
||||
return True
|
||||
@@ -4164,8 +4173,7 @@ async def _calculate_subscription_period_price(
|
||||
subscription_service: SubscriptionService | None = None,
|
||||
) -> int:
|
||||
"""Рассчитывает стоимость подписки для администратора с учётом всех параметров."""
|
||||
|
||||
service = subscription_service or SubscriptionService()
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
# Загружаем тариф для корректного расчёта в тарифном режиме
|
||||
if subscription.tariff_id:
|
||||
@@ -4174,13 +4182,13 @@ async def _calculate_subscription_period_price(
|
||||
except Exception as e:
|
||||
logger.warning('Не удалось загрузить тариф для расчёта цены', error=e)
|
||||
|
||||
return await service.calculate_renewal_price(
|
||||
subscription=subscription,
|
||||
period_days=period_days,
|
||||
db=db,
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
subscription,
|
||||
period_days,
|
||||
user=target_user,
|
||||
promo_group=getattr(target_user, 'promo_group', None),
|
||||
)
|
||||
return pricing.final_total
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -5309,9 +5317,24 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
|
||||
try:
|
||||
old_tariff_id = subscription.tariff_id
|
||||
|
||||
# Обновляем параметры подписки в соответствии с тарифом
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
extra_devices = 0
|
||||
if subscription.tariff_id:
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if old_tariff and old_tariff.device_limit:
|
||||
extra_devices = max(0, (subscription.device_limit or old_tariff.device_limit) - old_tariff.device_limit)
|
||||
|
||||
subscription.tariff_id = tariff.id
|
||||
subscription.device_limit = tariff.device_limit
|
||||
|
||||
new_base = tariff.device_limit or 1
|
||||
new_total = new_base + extra_devices
|
||||
effective_max = tariff.max_device_limit or (
|
||||
settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
|
||||
)
|
||||
if effective_max and new_total > effective_max:
|
||||
new_total = effective_max
|
||||
subscription.device_limit = new_total
|
||||
|
||||
subscription.traffic_limit_gb = tariff.traffic_limit_gb
|
||||
subscription.connected_squads = tariff.allowed_squads or []
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
@@ -5364,7 +5387,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Тариф успешно изменен</b>\n\n'
|
||||
f'Новый тариф: <b>{tariff.name}</b>\n'
|
||||
f'• Устройства: {tariff.device_limit}\n'
|
||||
f'• Устройства: {subscription.device_limit}\n'
|
||||
f'• Трафик: {"♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"}\n'
|
||||
f'• Серверы: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.crud.transaction import get_user_transactions
|
||||
from app.database.models import TransactionType, User
|
||||
from app.handlers.subscription.autopay import handle_confirm_unlink, handle_saved_cards_list, handle_unlink_card
|
||||
from app.keyboards.inline import (
|
||||
get_back_keyboard,
|
||||
get_balance_keyboard,
|
||||
@@ -404,10 +405,7 @@ async def handle_balance_history_pagination(callback: types.CallbackQuery, db_us
|
||||
@error_handler
|
||||
async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.payment_utils import get_payment_methods_text
|
||||
from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -430,139 +428,7 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
# Добавляем информацию о текущем тарифе пользователя
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
tariff_info = ''
|
||||
if subscription and not subscription.is_trial:
|
||||
# Рассчитываем приблизительную стоимость продления на 30 дней
|
||||
duration_days = 30 # Берем для примера 30 дней
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
current_connected_squads = subscription.connected_squads or []
|
||||
current_device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
try:
|
||||
# Получаем цены для текущих параметров
|
||||
from app.config import PERIOD_PRICES
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
# В режиме тарифов берём цену из тарифа пользователя
|
||||
tariff = None
|
||||
tariff_price_found = False
|
||||
base_price_original = 0
|
||||
if settings.is_tariffs_mode() and subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and tariff.period_prices:
|
||||
base_price_original = tariff.period_prices.get(str(duration_days), 0)
|
||||
if base_price_original > 0:
|
||||
tariff_price_found = True
|
||||
|
||||
# Если не нашли в тарифе - используем PERIOD_PRICES
|
||||
if base_price_original <= 0:
|
||||
base_price_original = PERIOD_PRICES.get(duration_days, 0)
|
||||
|
||||
if tariff_price_found:
|
||||
# Тарифный режим: серверы и трафик включены в цену тарифа.
|
||||
# Порядок: база + устройства → скидка на полную сумму (как в calculate_renewal_price).
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
original_price = base_price_original
|
||||
|
||||
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
|
||||
device_limit = (
|
||||
subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
|
||||
)
|
||||
extra_devices = max(0, device_limit - tariff_device_limit)
|
||||
device_price_per_unit = (
|
||||
tariff.device_price_kopeks
|
||||
if tariff and tariff.device_price_kopeks is not None
|
||||
else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
months_in_period = calculate_months_from_days(duration_days)
|
||||
devices_price = extra_devices * device_price_per_unit * months_in_period
|
||||
original_price += devices_price
|
||||
|
||||
# Скидка промогруппы на полную сумму (база + устройства)
|
||||
period_discount_percent = db_user.get_promo_discount('period', duration_days)
|
||||
discount_total = original_price * period_discount_percent // 100
|
||||
total_price = original_price - discount_total
|
||||
|
||||
# Promo-offer скидка (временная)
|
||||
promo_offer_percent = get_user_active_promo_discount_percent(db_user)
|
||||
if promo_offer_percent > 0:
|
||||
promo_offer_discount = total_price * promo_offer_percent // 100
|
||||
total_price = total_price - promo_offer_discount
|
||||
else:
|
||||
# Классический режим: серверы + трафик + устройства считаются отдельно
|
||||
period_discount_percent = db_user.get_promo_discount('period', duration_days)
|
||||
base_price, base_discount_total = apply_percentage_discount(
|
||||
base_price_original,
|
||||
period_discount_percent,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
(
|
||||
servers_price_per_month,
|
||||
per_server_monthly_prices,
|
||||
) = await subscription_service.get_countries_price_by_uuids(
|
||||
current_connected_squads,
|
||||
db,
|
||||
promo_group_id=db_user.promo_group_id,
|
||||
)
|
||||
servers_discount_percent = db_user.get_promo_discount('servers', duration_days)
|
||||
total_servers_price = 0
|
||||
for server_price in per_server_monthly_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price,
|
||||
servers_discount_percent,
|
||||
)
|
||||
total_servers_price += discounted_per_month
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(current_traffic)
|
||||
traffic_discount_percent = db_user.get_promo_discount('traffic', duration_days)
|
||||
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
|
||||
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount('devices', duration_days)
|
||||
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
|
||||
months_in_period = calculate_months_from_days(duration_days)
|
||||
total_price = (
|
||||
base_price
|
||||
+ total_servers_price * months_in_period
|
||||
+ traffic_discounted_per_month * months_in_period
|
||||
+ devices_discounted_per_month * months_in_period
|
||||
)
|
||||
|
||||
traffic_value = current_traffic or 0
|
||||
if traffic_value <= 0:
|
||||
traffic_display = texts.t('TRAFFIC_UNLIMITED_SHORT', 'Безлимит')
|
||||
else:
|
||||
traffic_display = texts.format_traffic(traffic_value)
|
||||
|
||||
current_tariff_desc = (
|
||||
f'📱 Подписка: {len(current_connected_squads)} серверов, '
|
||||
f'{traffic_display}, {current_device_limit} устр.'
|
||||
)
|
||||
estimated_price_info = (
|
||||
f'💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней'
|
||||
)
|
||||
|
||||
tariff_info = f'\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}'
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'Не удалось рассчитать стоимость текущей подписки для пользователя', db_user_id=db_user.id, error=e
|
||||
)
|
||||
tariff_info = ''
|
||||
|
||||
full_text = payment_text + tariff_info
|
||||
full_text = payment_text
|
||||
|
||||
keyboard = get_payment_methods_keyboard(0, db_user.language)
|
||||
|
||||
@@ -1014,3 +880,7 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(handle_quick_amount_selection, F.data.startswith('quick_amount_'))
|
||||
|
||||
dp.callback_query.register(handle_topup_amount_callback, F.data.startswith('topup_amount|'))
|
||||
|
||||
dp.callback_query.register(handle_saved_cards_list, F.data == 'saved_cards_list')
|
||||
dp.callback_query.register(handle_unlink_card, F.data.startswith('unlink_card_'))
|
||||
dp.callback_query.register(handle_confirm_unlink, F.data.startswith('confirm_unlink_'))
|
||||
|
||||
+18
-10
@@ -1089,6 +1089,9 @@ def _get_subscription_status(user: User, texts, is_daily_tariff: bool = False) -
|
||||
if actual_status == 'disabled':
|
||||
return texts.t('SUB_STATUS_DISABLED', '⚫ Отключена')
|
||||
|
||||
if actual_status == 'limited':
|
||||
return texts.t('SUB_STATUS_LIMITED', '⚠️ Трафик исчерпан')
|
||||
|
||||
if actual_status == 'expired':
|
||||
return texts.t(
|
||||
'SUB_STATUS_EXPIRED',
|
||||
@@ -1294,16 +1297,18 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
|
||||
# Найти максимальный период <= баланса
|
||||
best_period = None
|
||||
best_price = 0
|
||||
best_pricing = None # Cache pricing result for reuse in finalize()
|
||||
|
||||
# Для продления используем PricingEngine (единый расчёт для всех поверхностей).
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
# Для продления используем тот же сервис, что и при реальном списании,
|
||||
# чтобы сумма проверки совпадала с суммой списания.
|
||||
renewal_service = SubscriptionRenewalService() if subscription else None
|
||||
|
||||
try:
|
||||
for period in available_periods:
|
||||
if subscription and renewal_service:
|
||||
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, period)
|
||||
price = pricing.final_total
|
||||
if subscription:
|
||||
pricing_result = await pricing_engine.calculate_renewal_price(db, subscription, period, user=db_user)
|
||||
price = pricing_result.final_total
|
||||
else:
|
||||
price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
|
||||
@@ -1311,14 +1316,15 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
|
||||
if price <= balance:
|
||||
best_period = period
|
||||
best_price = price
|
||||
best_pricing = pricing_result if subscription else None
|
||||
break
|
||||
|
||||
if not best_period:
|
||||
# Показать сколько не хватает для минимального периода
|
||||
min_period = min(available_periods) if available_periods else 30
|
||||
if subscription and renewal_service:
|
||||
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, min_period)
|
||||
min_price = pricing.final_total
|
||||
if subscription:
|
||||
min_pricing = await pricing_engine.calculate_renewal_price(db, subscription, min_period, user=db_user)
|
||||
min_price = min_pricing.final_total
|
||||
else:
|
||||
min_price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
|
||||
@@ -1336,8 +1342,10 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
try:
|
||||
if subscription:
|
||||
# Продление существующей подписки
|
||||
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, best_period)
|
||||
# Продление существующей подписки (reuse cached pricing from loop above)
|
||||
if best_pricing is None:
|
||||
raise ValueError('best_pricing is None despite best_period being set')
|
||||
pricing = best_pricing
|
||||
|
||||
await renewal_service.finalize(
|
||||
db,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -37,10 +38,14 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not db_user.referral_code:
|
||||
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
|
||||
return
|
||||
|
||||
summary = await get_user_referral_summary(db, db_user.id)
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
|
||||
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
|
||||
|
||||
referral_text = (
|
||||
texts.t('REFERRAL_PROGRAM_TITLE', '👥 <b>Реферальная программа</b>')
|
||||
@@ -229,17 +234,22 @@ async def show_referral_qr(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
):
|
||||
await callback.answer()
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not db_user.referral_code:
|
||||
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
|
||||
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
|
||||
|
||||
qr_dir = Path('data') / 'referral_qr'
|
||||
qr_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_path = qr_dir / f'{db_user.id}.png'
|
||||
link_hash = hashlib.md5(referral_link.encode()).hexdigest()[:8]
|
||||
file_path = qr_dir / f'{db_user.id}_{link_hash}.png'
|
||||
if not file_path.exists():
|
||||
img = qrcode.make(referral_link)
|
||||
img.save(file_path)
|
||||
@@ -470,8 +480,12 @@ async def show_referral_analytics(callback: types.CallbackQuery, db_user: User,
|
||||
async def create_invite_message(callback: types.CallbackQuery, db_user: User):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not db_user.referral_code:
|
||||
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
|
||||
return
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
|
||||
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
|
||||
|
||||
invite_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!')
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
@@ -189,7 +190,7 @@ def _format_server_lines(
|
||||
else:
|
||||
latency_text = texts.t('SERVER_STATUS_OFFLINE', 'нет ответа')
|
||||
|
||||
name = server.display_name or server.name
|
||||
name = html.escape(server.display_name or server.name)
|
||||
flag_prefix = f'{server.flag} ' if server.flag else ''
|
||||
server_line = f'{flag_prefix}{name} — {latency_text}'
|
||||
lines.append(f'<blockquote>{server_line}</blockquote>')
|
||||
|
||||
@@ -285,6 +285,90 @@ async def _handle_trial_payment(
|
||||
return False
|
||||
|
||||
|
||||
_PURCHASE_TOKEN_RE = __import__('re').compile(r'^[A-Za-z0-9_\-]{10,100}$')
|
||||
|
||||
|
||||
async def _handle_guest_purchase_payment(
|
||||
message: types.Message,
|
||||
db: AsyncSession,
|
||||
user,
|
||||
stars_amount: int,
|
||||
payload: str,
|
||||
telegram_payment_charge_id: str,
|
||||
):
|
||||
"""Обработка Stars платежа для гостевой покупки (подарочная подписка из кабинета)."""
|
||||
from app.database.crud.landing import get_purchase_by_token
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
try:
|
||||
purchase_token = payload[len('guest_purchase_') :]
|
||||
if not purchase_token or not _PURCHASE_TOKEN_RE.match(purchase_token):
|
||||
logger.error('Invalid purchase_token format in guest_purchase payload', payload=payload)
|
||||
await message.answer('❌ Ошибка: неверный формат платежа.')
|
||||
return
|
||||
|
||||
# Verify Stars amount matches expected price (±5% tolerance for conversion rounding)
|
||||
existing = await get_purchase_by_token(db, purchase_token)
|
||||
if existing and existing.amount_kopeks:
|
||||
expected_stars = max(1, settings.rubles_to_stars(existing.amount_kopeks / 100))
|
||||
tolerance = max(1, round(expected_stars * 0.05))
|
||||
if abs(stars_amount - expected_stars) > tolerance:
|
||||
logger.error(
|
||||
'Stars amount mismatch for guest purchase',
|
||||
paid_stars=stars_amount,
|
||||
expected_stars=expected_stars,
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
)
|
||||
await message.answer('❌ Сумма оплаты не совпадает с ожидаемой.')
|
||||
return
|
||||
|
||||
# Calculate kopeks from stars
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
|
||||
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
|
||||
# Build metadata matching what other providers use
|
||||
metadata = {
|
||||
'purpose': 'guest_purchase',
|
||||
'purchase_token': purchase_token,
|
||||
}
|
||||
|
||||
result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=amount_kopeks,
|
||||
provider_payment_id=telegram_payment_charge_id,
|
||||
provider_name='telegram_stars',
|
||||
skip_amount_check=True,
|
||||
)
|
||||
|
||||
if result is True:
|
||||
await message.answer(
|
||||
'🎁 <b>Подарочная подписка успешно оплачена!</b>\n\n'
|
||||
f'⭐ Потрачено: {stars_amount} Stars\n\n'
|
||||
'Подарок будет доставлен получателю.',
|
||||
parse_mode='HTML',
|
||||
)
|
||||
logger.info(
|
||||
'✅ Guest purchase fulfilled via Stars',
|
||||
user_id=user.id,
|
||||
stars_amount=stars_amount,
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
)
|
||||
elif result is False:
|
||||
await message.answer(
|
||||
'❌ Произошла ошибка при обработке подарочной подписки. Обратитесь в поддержку.',
|
||||
)
|
||||
else:
|
||||
logger.error('try_fulfill_guest_purchase returned None for Stars gift', payload=payload)
|
||||
await message.answer('❌ Ошибка обработки платежа. Обратитесь в поддержку.')
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error handling guest purchase Stars payment', error=e, exc_info=True)
|
||||
await message.answer(
|
||||
'❌ Произошла ошибка при обработке подарочной подписки. Обратитесь в поддержку.',
|
||||
)
|
||||
|
||||
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
texts = get_texts(DEFAULT_LANGUAGE)
|
||||
|
||||
@@ -296,7 +380,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
invoice_payload=query.invoice_payload,
|
||||
)
|
||||
|
||||
allowed_prefixes = ('balance_', 'admin_stars_test_', 'simple_sub_', 'wheel_spin_', 'trial_')
|
||||
allowed_prefixes = ('balance_', 'admin_stars_test_', 'simple_sub_', 'wheel_spin_', 'trial_', 'guest_purchase_')
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
|
||||
logger.warning('Невалидный payload', invoice_payload=query.invoice_payload)
|
||||
@@ -402,6 +486,18 @@ async def handle_successful_payment(message: types.Message, db: AsyncSession, st
|
||||
)
|
||||
return
|
||||
|
||||
# Обработка оплаты гостевой покупки (подарочная подписка из кабинета)
|
||||
if payment.invoice_payload and payment.invoice_payload.startswith('guest_purchase_'):
|
||||
await _handle_guest_purchase_payment(
|
||||
message=message,
|
||||
db=db,
|
||||
user=user,
|
||||
stars_amount=payment.total_amount,
|
||||
payload=payment.invoice_payload,
|
||||
telegram_payment_charge_id=payment.telegram_payment_charge_id,
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
state_data = await state.get_data()
|
||||
|
||||
@@ -754,6 +754,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
|
||||
await db.execute(delete(Transaction).where(Transaction.user_id == user.id))
|
||||
|
||||
if user.balance_kopeks > 0:
|
||||
logger.warning(
|
||||
'⚠️ DELETED-восстановление: обнуляем ненулевой баланс',
|
||||
telegram_id=user.telegram_id,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
)
|
||||
|
||||
user.status = UserStatus.ACTIVE.value
|
||||
user.balance_kopeks = 0
|
||||
user.remnawave_uuid = None
|
||||
@@ -1344,6 +1351,13 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
# Prevent self-referral when partner re-registers via own campaign link
|
||||
safe_referrer_id = referrer_id if referrer_id != existing_user.id else None
|
||||
|
||||
if existing_user.balance_kopeks > 0:
|
||||
logger.warning(
|
||||
'⚠️ DELETED-восстановление: обнуляем ненулевой баланс',
|
||||
telegram_id=existing_user.telegram_id,
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
)
|
||||
|
||||
existing_user.username = callback.from_user.username
|
||||
existing_user.first_name = callback.from_user.first_name
|
||||
existing_user.last_name = callback.from_user.last_name
|
||||
@@ -1639,6 +1653,13 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
# Prevent self-referral when partner re-registers via own campaign link
|
||||
safe_referrer_id = referrer_id if referrer_id != existing_user.id else None
|
||||
|
||||
if existing_user.balance_kopeks > 0:
|
||||
logger.warning(
|
||||
'⚠️ DELETED-восстановление: обнуляем ненулевой баланс',
|
||||
telegram_id=existing_user.telegram_id,
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
)
|
||||
|
||||
existing_user.username = message.from_user.username
|
||||
existing_user.first_name = message.from_user.first_name
|
||||
existing_user.last_name = message.from_user.last_name
|
||||
@@ -1897,6 +1918,9 @@ def _get_subscription_status(user, texts):
|
||||
if actual_status == 'disabled':
|
||||
return texts.t('SUB_STATUS_DISABLED', '⚫ Отключена')
|
||||
|
||||
if actual_status == 'limited':
|
||||
return texts.t('SUB_STATUS_LIMITED', '⚠️ Трафик исчерпан')
|
||||
|
||||
if actual_status == 'pending':
|
||||
return texts.t('SUB_STATUS_PENDING', '⏳ Ожидает активации')
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
from .autopay import (
|
||||
handle_autopay_menu,
|
||||
handle_confirm_unlink,
|
||||
handle_saved_cards_list,
|
||||
handle_subscription_cancel,
|
||||
handle_subscription_config_back,
|
||||
handle_unlink_card,
|
||||
set_autopay_days,
|
||||
show_autopay_days,
|
||||
toggle_autopay,
|
||||
@@ -157,6 +160,7 @@ __all__ = [
|
||||
'handle_app_selection',
|
||||
'handle_autopay_menu',
|
||||
'handle_change_devices',
|
||||
'handle_confirm_unlink',
|
||||
'handle_connect_subscription',
|
||||
'handle_device_guide',
|
||||
'handle_device_management',
|
||||
@@ -172,12 +176,14 @@ __all__ = [
|
||||
'handle_promo_offer_close',
|
||||
'handle_reset_devices',
|
||||
'handle_reset_traffic',
|
||||
'handle_saved_cards_list',
|
||||
'handle_single_device_reset',
|
||||
'handle_specific_app_guide',
|
||||
'handle_subscription_cancel',
|
||||
'handle_subscription_config_back',
|
||||
'handle_subscription_settings',
|
||||
'handle_switch_traffic',
|
||||
'handle_unlink_card',
|
||||
'invalidate_app_config_cache',
|
||||
'load_app_config_async',
|
||||
'normalize_app',
|
||||
|
||||
@@ -3,13 +3,20 @@ from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.saved_payment_method import (
|
||||
deactivate_payment_method,
|
||||
get_active_payment_methods_by_user,
|
||||
)
|
||||
from app.database.crud.subscription import update_subscription_autopay
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import (
|
||||
_get_payment_method_display_name,
|
||||
get_autopay_days_keyboard,
|
||||
get_autopay_keyboard,
|
||||
get_confirm_unlink_keyboard,
|
||||
get_countries_keyboard,
|
||||
get_devices_keyboard,
|
||||
get_saved_cards_keyboard,
|
||||
get_subscription_period_keyboard,
|
||||
get_traffic_packages_keyboard,
|
||||
)
|
||||
@@ -134,6 +141,89 @@ async def set_autopay_days(callback: types.CallbackQuery, db_user: User, db: Asy
|
||||
await handle_autopay_menu(callback, db_user, db)
|
||||
|
||||
|
||||
async def handle_saved_cards_list(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
cards = await get_active_payment_methods_by_user(db, db_user.id)
|
||||
|
||||
if not cards:
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'SAVED_CARDS_EMPTY',
|
||||
'💳 <b>Привязанные карты</b>\n\nНет привязанных карт.\n'
|
||||
'Карта привяжется автоматически при следующем пополнении баланса.',
|
||||
),
|
||||
reply_markup=get_saved_cards_keyboard([], db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'SAVED_CARDS_TITLE',
|
||||
'💳 <b>Привязанные карты</b>\n\nВыберите карту для отвязки:',
|
||||
),
|
||||
reply_markup=get_saved_cards_keyboard(cards, db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_unlink_card(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
card_id = int(callback.data.split('_')[-1])
|
||||
|
||||
cards = await get_active_payment_methods_by_user(db, db_user.id)
|
||||
card = next((c for c in cards if c.id == card_id), None)
|
||||
|
||||
if not card:
|
||||
await callback.answer(
|
||||
texts.t('SAVED_CARDS_UNLINK_ERROR', '❌ Не удалось отвязать карту'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
card_label = _get_payment_method_display_name(card, db_user.language)
|
||||
text = texts.t(
|
||||
'SAVED_CARDS_CONFIRM_UNLINK',
|
||||
'Вы уверены, что хотите отвязать карту <b>{card}</b>?\n\n'
|
||||
'После отвязки автоплатеж не сможет использовать эту карту.',
|
||||
).format(card=card_label)
|
||||
|
||||
if len(cards) == 1:
|
||||
text += texts.t(
|
||||
'SAVED_CARDS_LAST_CARD_WARNING',
|
||||
'\n\n⚠️ <b>Внимание:</b> это ваша последняя привязанная карта. '
|
||||
'После отвязки автоплатеж не сможет списывать средства.',
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_confirm_unlink_keyboard(card_id, db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_confirm_unlink(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
card_id = int(callback.data.split('_')[-1])
|
||||
|
||||
success = await deactivate_payment_method(db, card_id, db_user.id)
|
||||
|
||||
if success:
|
||||
await callback.answer(
|
||||
texts.t('SAVED_CARDS_UNLINKED', '✅ Карта отвязана'),
|
||||
)
|
||||
else:
|
||||
await callback.answer(
|
||||
texts.t('SAVED_CARDS_UNLINK_ERROR', '❌ Не удалось отвязать карту'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Return to the updated cards list
|
||||
await handle_saved_cards_list(callback, db_user, db)
|
||||
|
||||
|
||||
async def handle_subscription_config_back(
|
||||
callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession
|
||||
):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from aiogram import types
|
||||
@@ -66,7 +67,7 @@ async def handle_add_countries(callback: types.CallbackQuery, db_user: User, db:
|
||||
current_countries_names = []
|
||||
for country in countries:
|
||||
if country['uuid'] in current_countries:
|
||||
current_countries_names.append(country['name'])
|
||||
current_countries_names.append(html.escape(country['name']))
|
||||
|
||||
current_list = (
|
||||
'\n'.join(f'• {name}' for name in current_countries_names)
|
||||
@@ -659,8 +660,8 @@ def _build_countries_selection_text(countries: list[dict], base_text: str) -> st
|
||||
continue
|
||||
desc = country.get('description', '').strip()
|
||||
if desc:
|
||||
name = country.get('name', '')
|
||||
descriptions.append(f'<b>{name}</b>\n{desc}')
|
||||
name = html.escape(country.get('name', ''))
|
||||
descriptions.append(f'<b>{name}</b>\n{html.escape(desc)}')
|
||||
|
||||
if not descriptions:
|
||||
return base_text
|
||||
@@ -841,9 +842,9 @@ async def confirm_add_countries_to_subscription(
|
||||
|
||||
total_price += charged_price
|
||||
total_discount_value += int(discount_per_month * charged_days / 30)
|
||||
new_countries_names.append(country['name'])
|
||||
new_countries_names.append(html.escape(country['name']))
|
||||
if country['uuid'] in removed_countries:
|
||||
removed_countries_names.append(country['name'])
|
||||
removed_countries_names.append(html.escape(country['name']))
|
||||
|
||||
if new_countries and db_user.balance_kopeks < total_price:
|
||||
missing_kopeks = total_price - db_user.balance_kopeks
|
||||
|
||||
@@ -82,7 +82,7 @@ async def get_servers_display_names(squad_uuids: list[str]) -> str:
|
||||
for uuid in squad_uuids:
|
||||
server = await get_server_squad_by_uuid(db, uuid)
|
||||
if server:
|
||||
server_names.append(server.display_name)
|
||||
server_names.append(html_mod.escape(server.display_name))
|
||||
logger.debug('Найден сервер в БД', uuid=uuid, display_name=server.display_name)
|
||||
else:
|
||||
logger.warning('Сервер с UUID не найден в БД', uuid=uuid)
|
||||
@@ -92,7 +92,7 @@ async def get_servers_display_names(squad_uuids: list[str]) -> str:
|
||||
for uuid in squad_uuids:
|
||||
for country in countries:
|
||||
if country['uuid'] == uuid:
|
||||
server_names.append(country['name'])
|
||||
server_names.append(html_mod.escape(country['name']))
|
||||
logger.debug('Найден сервер в кэше', uuid=uuid, country=country['name'])
|
||||
break
|
||||
|
||||
@@ -606,7 +606,7 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -614,6 +614,10 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if db_user.remnawave_uuid and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
|
||||
|
||||
# При уменьшении лимита - удалить лишние устройства (последние подключённые)
|
||||
devices_reset_count = 0
|
||||
if new_devices_count < current_devices and db_user.remnawave_uuid:
|
||||
@@ -1285,7 +1289,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -1293,6 +1297,10 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if db_user.remnawave_uuid and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -74,7 +75,7 @@ async def _prepare_subscription_summary(
|
||||
if country['uuid'] in selected_country_ids:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
countries_price_per_month += server_price_per_month
|
||||
selected_countries_names.append(country['name'])
|
||||
selected_countries_names.append(html.escape(country['name']))
|
||||
server_monthly_prices.append(server_price_per_month)
|
||||
|
||||
servers_discount_percent = db_user.get_promo_discount(
|
||||
@@ -472,7 +473,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
days_left=max(0, subscription.days_left),
|
||||
traffic_used=texts.format_traffic(subscription.traffic_used_gb, is_limit=False),
|
||||
traffic_limit=traffic_text,
|
||||
countries_count=len(subscription.connected_squads),
|
||||
countries_count=len(subscription.connected_squads or []),
|
||||
devices_used=devices_used,
|
||||
devices_limit=subscription.device_limit,
|
||||
autopay_status='✅ Включен' if subscription.autopay_enabled else '⌛ Выключен',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -96,12 +97,11 @@ from app.handlers.simple_subscription import (
|
||||
_get_simple_subscription_payment_keyboard,
|
||||
)
|
||||
from app.states import SubscriptionStates
|
||||
from app.utils.price_display import PriceInfo, calculate_user_price, format_price_text
|
||||
from app.utils.price_display import PriceInfo, format_price_text
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
calculate_months_from_days,
|
||||
format_period_description,
|
||||
validate_pricing_calculation,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
@@ -117,7 +117,7 @@ from .autopay import (
|
||||
show_autopay_days,
|
||||
toggle_autopay,
|
||||
)
|
||||
from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, update_traffic_prices
|
||||
from .common import _get_promo_offer_discount_percent, update_traffic_prices
|
||||
from .countries import (
|
||||
_build_countries_selection_text,
|
||||
_get_available_countries,
|
||||
@@ -208,7 +208,11 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
if subscription.status == 'disabled':
|
||||
if subscription.status == 'limited':
|
||||
actual_status = 'limited'
|
||||
status_display = texts.t('SUBSCRIPTION_STATUS_LIMITED', 'Трафик исчерпан')
|
||||
status_emoji = '⚠️'
|
||||
elif subscription.status == 'disabled':
|
||||
actual_status = 'disabled'
|
||||
status_display = texts.t('SUBSCRIPTION_STATUS_DISABLED', 'Приостановлена')
|
||||
status_emoji = '⏸️'
|
||||
@@ -623,7 +627,7 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
|
||||
tariff_squads = await get_server_squads_by_uuids(db, trial_tariff.allowed_squads)
|
||||
if tariff_squads:
|
||||
if len(tariff_squads) == 1:
|
||||
trial_server_name = tariff_squads[0].display_name
|
||||
trial_server_name = html.escape(tariff_squads[0].display_name)
|
||||
else:
|
||||
trial_server_name = texts.t(
|
||||
'TRIAL_SERVER_RANDOM_POOL',
|
||||
@@ -633,7 +637,7 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
|
||||
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
|
||||
if trial_squads:
|
||||
if len(trial_squads) == 1:
|
||||
trial_server_name = trial_squads[0].display_name
|
||||
trial_server_name = html.escape(trial_squads[0].display_name)
|
||||
else:
|
||||
trial_server_name = texts.t(
|
||||
'TRIAL_SERVER_RANDOM_POOL',
|
||||
@@ -1590,7 +1594,7 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
available_periods = settings.get_available_renewal_periods()
|
||||
renewal_prices = {}
|
||||
@@ -1598,72 +1602,18 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
|
||||
|
||||
for days in available_periods:
|
||||
try:
|
||||
months_in_period = calculate_months_from_days(days)
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
# 1. Calculate period price with promo group discount using unified system
|
||||
base_price_original = PERIOD_PRICES.get(days, 0)
|
||||
period_price_info = calculate_user_price(db_user, base_price_original, days, 'period')
|
||||
|
||||
# 2. Calculate servers price with promo group discount
|
||||
servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids(
|
||||
subscription.connected_squads,
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
promo_group_id=db_user.promo_group_id,
|
||||
)
|
||||
servers_total_base = servers_price_per_month * months_in_period
|
||||
servers_price_info = calculate_user_price(db_user, servers_total_base, days, 'servers')
|
||||
|
||||
# 3. Calculate devices price with promo group discount
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_total_base = devices_price_per_month * months_in_period
|
||||
devices_price_info = calculate_user_price(db_user, devices_total_base, days, 'devices')
|
||||
|
||||
# 4. Calculate traffic price with promo group discount
|
||||
# В режиме fixed_with_topup при продлении трафик сбрасывается до фиксированного лимита
|
||||
if settings.is_traffic_fixed():
|
||||
renewal_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
else:
|
||||
renewal_traffic_gb = subscription.traffic_limit_gb
|
||||
traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb)
|
||||
traffic_total_base = traffic_price_per_month * months_in_period
|
||||
traffic_price_info = calculate_user_price(db_user, traffic_total_base, days, 'traffic')
|
||||
|
||||
# 5. Calculate ORIGINAL price (before ALL discounts)
|
||||
total_original_price = (
|
||||
period_price_info.base_price
|
||||
+ servers_price_info.base_price
|
||||
+ devices_price_info.base_price
|
||||
+ traffic_price_info.base_price
|
||||
subscription,
|
||||
days,
|
||||
user=db_user,
|
||||
)
|
||||
|
||||
# 6. Sum prices with promo group discounts applied
|
||||
total_price = (
|
||||
period_price_info.final_price
|
||||
+ servers_price_info.final_price
|
||||
+ devices_price_info.final_price
|
||||
+ traffic_price_info.final_price
|
||||
)
|
||||
# original = price before ALL discounts, final = price with all discounts
|
||||
total_original_price = pricing.original_total
|
||||
|
||||
# 7. Apply promo offer discount on top of promo group discounts
|
||||
promo_component = _apply_promo_offer_discount(db_user, total_price)
|
||||
|
||||
# Store: original = price before discounts, final = price with all discounts
|
||||
renewal_prices[days] = {
|
||||
'final': promo_component['discounted'],
|
||||
'final': pricing.final_total,
|
||||
'original': total_original_price,
|
||||
}
|
||||
|
||||
@@ -1723,7 +1673,7 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
|
||||
f'Осталось дней: {subscription.days_left}',
|
||||
'',
|
||||
'<b>Ваша текущая конфигурация:</b>',
|
||||
f'🌍 Серверов: {len(subscription.connected_squads)}',
|
||||
f'🌍 Серверов: {len(subscription.connected_squads or [])}',
|
||||
f'📊 Трафик: {texts.format_traffic(subscription.traffic_limit_gb)}',
|
||||
]
|
||||
|
||||
@@ -1765,6 +1715,9 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
|
||||
|
||||
|
||||
async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
if not callback.data:
|
||||
await callback.answer('⚠ Ошибка данных', show_alert=True)
|
||||
return
|
||||
days = int(callback.data.split('_')[2])
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -1782,143 +1735,45 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
|
||||
await callback.answer('⚠ У вас нет активной подписки', show_alert=True)
|
||||
return
|
||||
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
from app.services.subscription_renewal_service import SubscriptionRenewalChargeError, SubscriptionRenewalService
|
||||
|
||||
months_in_period = calculate_months_from_days(days)
|
||||
old_end_date = subscription.end_date
|
||||
server_uuid_prices: dict[str, int] = {}
|
||||
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
base_price_original = PERIOD_PRICES.get(days, 0)
|
||||
period_discount_percent = db_user.get_promo_discount('period', days)
|
||||
base_price, base_discount_total = apply_percentage_discount(
|
||||
base_price_original,
|
||||
period_discount_percent,
|
||||
)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids(
|
||||
subscription.connected_squads,
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
promo_group_id=db_user.promo_group_id,
|
||||
)
|
||||
servers_discount_percent = db_user.get_promo_discount(
|
||||
'servers',
|
||||
subscription,
|
||||
days,
|
||||
user=db_user,
|
||||
)
|
||||
total_servers_price = 0
|
||||
total_servers_discount = 0
|
||||
|
||||
for squad_uuid, server_monthly_price in zip(
|
||||
subscription.connected_squads, per_server_monthly_prices, strict=False
|
||||
):
|
||||
discount_per_month = server_monthly_price * servers_discount_percent // 100
|
||||
discounted_per_month = server_monthly_price - discount_per_month
|
||||
total_servers_price += discounted_per_month * months_in_period
|
||||
total_servers_discount += discount_per_month * months_in_period
|
||||
server_uuid_prices[squad_uuid] = discounted_per_month * months_in_period
|
||||
|
||||
discounted_servers_price_per_month = servers_price_per_month - (
|
||||
servers_price_per_month * servers_discount_percent // 100
|
||||
)
|
||||
price = pricing.final_total
|
||||
|
||||
# Derive device_limit from subscription (same logic as engine)
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
'devices',
|
||||
days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_price_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_price_per_month * months_in_period
|
||||
# Derive renewal_traffic_gb for cart data
|
||||
renewal_traffic_gb = subscription.traffic_limit_gb
|
||||
|
||||
# В режиме fixed_with_topup при продлении трафик сбрасывается до фиксированного лимита
|
||||
if settings.is_traffic_fixed():
|
||||
renewal_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
else:
|
||||
renewal_traffic_gb = subscription.traffic_limit_gb
|
||||
traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb)
|
||||
traffic_discount_percent = db_user.get_promo_discount(
|
||||
'traffic',
|
||||
days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_price_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_price_per_month * months_in_period
|
||||
|
||||
price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
original_price = price
|
||||
promo_component = _apply_promo_offer_discount(db_user, price)
|
||||
if promo_component['discount'] > 0:
|
||||
price = promo_component['discounted']
|
||||
|
||||
monthly_additions = (
|
||||
discounted_servers_price_per_month + discounted_devices_price_per_month + discounted_traffic_price_per_month
|
||||
)
|
||||
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, original_price)
|
||||
|
||||
if not is_valid:
|
||||
logger.error('Ошибка в расчете цены продления для пользователя', telegram_id=db_user.telegram_id)
|
||||
await callback.answer('Ошибка расчета цены. Обратитесь в поддержку.', show_alert=True)
|
||||
return
|
||||
# Promo offer discount info for downstream consume_promo_offer flag
|
||||
promo_offer_discount = pricing.promo_offer_discount
|
||||
offer_pct = pricing.breakdown.get('offer_discount_pct', 0)
|
||||
|
||||
logger.info(
|
||||
'💰 Расчет продления подписки на дней ( мес)',
|
||||
'💰 Расчет продления подписки (PricingEngine)',
|
||||
subscription_id=subscription.id,
|
||||
days=days,
|
||||
months_in_period=months_in_period,
|
||||
base_price=pricing.base_price,
|
||||
servers_price=pricing.servers_price,
|
||||
traffic_price=pricing.traffic_price,
|
||||
devices_price=pricing.devices_price,
|
||||
group_discount=pricing.promo_group_discount,
|
||||
offer_discount=pricing.promo_offer_discount,
|
||||
final_total=pricing.final_total,
|
||||
)
|
||||
base_log = f' 📅 Период {days} дней: {base_price_original / 100}₽'
|
||||
if base_discount_total > 0:
|
||||
base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)'
|
||||
logger.info(base_log)
|
||||
if total_servers_price > 0:
|
||||
logger.info(
|
||||
f' 🌐 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period}'
|
||||
f' = {total_servers_price / 100}₽'
|
||||
+ (
|
||||
f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)'
|
||||
if total_servers_discount > 0
|
||||
else ''
|
||||
)
|
||||
)
|
||||
if total_devices_price > 0:
|
||||
logger.info(
|
||||
f' 📱 Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period}'
|
||||
f' = {total_devices_price / 100}₽'
|
||||
+ (
|
||||
f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period / 100}₽)'
|
||||
if devices_discount_percent > 0 and devices_discount_per_month > 0
|
||||
else ''
|
||||
)
|
||||
)
|
||||
if total_traffic_price > 0:
|
||||
logger.info(
|
||||
f' 📊 Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period}'
|
||||
f' = {total_traffic_price / 100}₽'
|
||||
+ (
|
||||
f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period / 100}₽)'
|
||||
if traffic_discount_percent > 0 and traffic_discount_per_month > 0
|
||||
else ''
|
||||
)
|
||||
)
|
||||
if promo_component['discount'] > 0:
|
||||
logger.info(
|
||||
'🎯 Промо-предложение: -₽ (%)',
|
||||
promo_component=promo_component['discount'] / 100,
|
||||
promo_component_2=promo_component['percent'],
|
||||
)
|
||||
logger.info('💎 ИТОГО: ₽', price=price / 100)
|
||||
|
||||
except Exception as e:
|
||||
@@ -1955,7 +1810,7 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
|
||||
'missing_amount': missing_kopeks,
|
||||
'return_to_cart': True,
|
||||
'description': f'Продление подписки на {days} дней',
|
||||
'consume_promo_offer': bool(promo_component['discount'] > 0),
|
||||
'consume_promo_offer': bool(promo_offer_discount > 0),
|
||||
'device_limit': device_limit,
|
||||
'traffic_limit_gb': renewal_traffic_gb,
|
||||
}
|
||||
@@ -1974,183 +1829,57 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
old_traffic_gb = subscription.traffic_limit_gb
|
||||
renewal_description = f'Продление подписки на {days} дней ({months_in_period} мес)'
|
||||
|
||||
try:
|
||||
success = await subtract_user_balance(
|
||||
renewal_service = SubscriptionRenewalService()
|
||||
result = await renewal_service.finalize(
|
||||
db,
|
||||
db_user,
|
||||
price,
|
||||
f'Продление подписки на {days} дней',
|
||||
consume_promo_offer=promo_component['discount'] > 0,
|
||||
mark_as_paid_subscription=True,
|
||||
subscription,
|
||||
pricing,
|
||||
description=renewal_description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
if not success:
|
||||
await callback.answer('⚠ Ошибка списания средств', show_alert=True)
|
||||
return
|
||||
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
was_expired = subscription.status in (
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
) or (subscription.end_date is not None and subscription.end_date <= current_time)
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
new_end_date = subscription.end_date + timedelta(days=days)
|
||||
else:
|
||||
new_end_date = current_time + timedelta(days=days)
|
||||
|
||||
subscription.end_date = new_end_date
|
||||
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = current_time
|
||||
|
||||
# При продлении истёкшей подписки — сбрасываем докупки трафика
|
||||
if was_expired:
|
||||
from sqlalchemy import delete as sql_delete_tp
|
||||
|
||||
from app.database.models import TrafficPurchase as TrafficPurchaseModel
|
||||
|
||||
await db.execute(
|
||||
sql_delete_tp(TrafficPurchaseModel).where(TrafficPurchaseModel.subscription_id == subscription.id)
|
||||
)
|
||||
purchased = subscription.purchased_traffic_gb or 0
|
||||
if purchased > 0:
|
||||
old_traffic = subscription.traffic_limit_gb
|
||||
subscription.traffic_limit_gb = max(0, (subscription.traffic_limit_gb or 0) - purchased)
|
||||
logger.info(
|
||||
'Сброс докупок при продлении истёкшей подписки',
|
||||
old_traffic=old_traffic,
|
||||
new_traffic=subscription.traffic_limit_gb,
|
||||
)
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None
|
||||
if settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
|
||||
# В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита
|
||||
traffic_was_reset = False
|
||||
old_traffic_limit = subscription.traffic_limit_gb
|
||||
if settings.is_traffic_fixed():
|
||||
fixed_limit = settings.get_fixed_traffic_limit()
|
||||
if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0:
|
||||
traffic_was_reset = True
|
||||
subscription.traffic_limit_gb = fixed_limit
|
||||
from sqlalchemy import delete as sql_delete_fixed
|
||||
|
||||
from app.database.models import TrafficPurchase as TPFixed
|
||||
|
||||
await db.execute(sql_delete_fixed(TPFixed).where(TPFixed.subscription_id == subscription.id))
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None
|
||||
logger.info(
|
||||
'🔄 Сброс трафика при продлении: ГБ → ГБ',
|
||||
old_traffic_limit=old_traffic_limit,
|
||||
fixed_limit=fixed_limit,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(db_user)
|
||||
|
||||
# ensure freshly loaded values are available even if SQLAlchemy expires
|
||||
# attributes on subsequent access
|
||||
refreshed_end_date = subscription.end_date
|
||||
refreshed_balance = db_user.balance_kopeks
|
||||
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids
|
||||
from app.database.crud.subscription import add_subscription_servers
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, subscription.connected_squads)
|
||||
if server_ids:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad.id, ServerSquad.squad_uuid).where(ServerSquad.id.in_(server_ids))
|
||||
)
|
||||
id_to_uuid = {row.id: row.squad_uuid for row in result}
|
||||
default_price = total_servers_price // len(server_ids) if server_ids else 0
|
||||
server_prices_for_period = [
|
||||
server_uuid_prices.get(id_to_uuid.get(server_id, ''), default_price) for server_id in server_ids
|
||||
]
|
||||
await add_subscription_servers(db, subscription, server_ids, server_prices_for_period)
|
||||
|
||||
try:
|
||||
remnawave_result = await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason='продление подписки',
|
||||
)
|
||||
if remnawave_result:
|
||||
logger.info('✅ RemnaWave обновлен успешно')
|
||||
else:
|
||||
logger.error('⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE')
|
||||
except Exception as e:
|
||||
logger.error('⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE', error=e)
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f'Продление подписки на {days} дней ({months_in_period} мес)',
|
||||
)
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_extension_notification(
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
transaction,
|
||||
days,
|
||||
old_end_date,
|
||||
new_end_date=refreshed_end_date,
|
||||
balance_after=refreshed_balance,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о продлении', error=e)
|
||||
|
||||
success_message = (
|
||||
'✅ Подписка успешно продлена!\n\n'
|
||||
f'⏰ Добавлено: {days} дней\n'
|
||||
f'Действует до: {format_local_datetime(refreshed_end_date, "%d.%m.%Y %H:%M")}\n\n'
|
||||
f'💰 Списано: {texts.format_price(price)}'
|
||||
)
|
||||
|
||||
# Добавляем уведомление о сбросе трафика
|
||||
if traffic_was_reset:
|
||||
fixed_limit = settings.get_fixed_traffic_limit()
|
||||
success_message += f'\n\n📊 Трафик сброшен до {fixed_limit} ГБ'
|
||||
|
||||
if promo_component['discount'] > 0:
|
||||
success_message += (
|
||||
f' (включая доп. скидку {promo_component["percent"]}%:'
|
||||
f' -{texts.format_price(promo_component["discount"])})'
|
||||
)
|
||||
|
||||
await callback.message.edit_text(success_message, reply_markup=get_back_keyboard(db_user.language))
|
||||
|
||||
logger.info(
|
||||
'✅ Пользователь продлил подписку на дней за ₽',
|
||||
telegram_id=db_user.telegram_id,
|
||||
days=days,
|
||||
price=price / 100,
|
||||
)
|
||||
|
||||
except SubscriptionRenewalChargeError:
|
||||
await callback.answer('⚠ Ошибка списания средств', show_alert=True)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error('⚠ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ', error=e)
|
||||
import traceback
|
||||
|
||||
logger.error('TRACEBACK', format_exc=traceback.format_exc())
|
||||
|
||||
await callback.message.edit_text(
|
||||
'⚠ Произошла ошибка при продлении подписки. Обратитесь в поддержку.',
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
refreshed_end_date = result.subscription.end_date
|
||||
await db.refresh(db_user)
|
||||
|
||||
success_message = (
|
||||
'✅ Подписка успешно продлена!\n\n'
|
||||
f'⏰ Добавлено: {days} дней\n'
|
||||
f'Действует до: {format_local_datetime(refreshed_end_date, "%d.%m.%Y %H:%M")}\n\n'
|
||||
f'💰 Списано: {texts.format_price(price)}'
|
||||
)
|
||||
|
||||
# Добавляем уведомление о сбросе трафика
|
||||
if settings.is_traffic_fixed() and result.subscription.traffic_limit_gb != old_traffic_gb:
|
||||
fixed_limit = settings.get_fixed_traffic_limit()
|
||||
success_message += f'\n\n📊 Трафик сброшен до {fixed_limit} ГБ'
|
||||
|
||||
if promo_offer_discount > 0:
|
||||
success_message += f' (включая доп. скидку {offer_pct}%: -{texts.format_price(promo_offer_discount)})'
|
||||
|
||||
await callback.message.edit_text(success_message, reply_markup=get_back_keyboard(db_user.language))
|
||||
|
||||
logger.info(
|
||||
'✅ Пользователь продлил подписку на дней за ₽',
|
||||
telegram_id=db_user.telegram_id,
|
||||
days=days,
|
||||
price=price / 100,
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
@@ -3151,7 +2880,7 @@ async def handle_subscription_settings(callback: types.CallbackQuery, db_user: U
|
||||
devices_limit_display = str(subscription.device_limit)
|
||||
|
||||
settings_text = settings_template.format(
|
||||
countries_count=len(subscription.connected_squads),
|
||||
countries_count=len(subscription.connected_squads or []),
|
||||
traffic_used=texts.format_traffic(subscription.traffic_used_gb, is_limit=False),
|
||||
traffic_limit=texts.format_traffic(subscription.traffic_limit_gb, is_limit=True),
|
||||
devices_used=devices_used,
|
||||
@@ -3215,7 +2944,11 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
was_paused = getattr(subscription, 'is_daily_paused', False)
|
||||
is_inactive = subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value)
|
||||
is_inactive = subscription.status in (
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
)
|
||||
needs_resume = was_paused or is_inactive
|
||||
|
||||
# При возобновлении проверяем баланс
|
||||
@@ -4547,7 +4280,32 @@ async def _extend_existing_subscription(
|
||||
current_subscription.updated_at = current_time
|
||||
|
||||
# Сохраняем изменения
|
||||
await db.commit()
|
||||
try:
|
||||
await db.commit()
|
||||
except Exception as commit_error:
|
||||
logger.error('Ошибка сохранения продления подписки', error=commit_error, exc_info=True)
|
||||
await db.rollback()
|
||||
# Compensating refund: balance was already committed by subtract_user_balance
|
||||
try:
|
||||
from app.database.crud.user import add_user_balance
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
price_kopeks,
|
||||
'Возврат: ошибка продления подписки',
|
||||
create_transaction=True,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
except Exception as refund_error:
|
||||
logger.critical(
|
||||
'CRITICAL: не удалось вернуть средства после ошибки продления',
|
||||
user_id=db_user.id,
|
||||
price_kopeks=price_kopeks,
|
||||
refund_error=refund_error,
|
||||
)
|
||||
await callback.answer('⚠ Ошибка продления подписки', show_alert=True)
|
||||
return
|
||||
await db.refresh(current_subscription)
|
||||
await db.refresh(db_user)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -577,12 +577,16 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
# add_subscription_traffic уже создаёт TrafficPurchase и обновляет все необходимые поля
|
||||
await add_subscription_traffic(db, subscription, traffic_gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if db_user.remnawave_uuid and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
@@ -834,12 +838,16 @@ async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if db_user.remnawave_uuid and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
|
||||
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
|
||||
+77
-2
@@ -1146,7 +1146,11 @@ def get_subscription_keyboard(
|
||||
|
||||
sub_status = getattr(subscription, 'status', None)
|
||||
is_paused = getattr(subscription, 'is_daily_paused', False)
|
||||
is_inactive = sub_status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value)
|
||||
is_inactive = sub_status in (
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
)
|
||||
|
||||
if is_inactive or is_paused:
|
||||
# Подписка остановлена (системой или пользователем) — показываем «Возобновить»
|
||||
@@ -1500,8 +1504,17 @@ def get_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark
|
||||
InlineKeyboardButton(text=texts.BALANCE_HISTORY, callback_data='balance_history'),
|
||||
InlineKeyboardButton(text=texts.BALANCE_TOP_UP, callback_data='balance_topup'),
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')],
|
||||
]
|
||||
if settings.YOOKASSA_RECURRENT_ENABLED:
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('SAVED_CARDS_BUTTON', '💳 Привязанные карты'),
|
||||
callback_data='saved_cards_list',
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
@@ -1937,6 +1950,68 @@ def get_autopay_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark
|
||||
)
|
||||
|
||||
|
||||
_PAYMENT_METHOD_LOCALE_KEYS: dict[str, tuple[str, str]] = {
|
||||
'bank_card': ('PAYMENT_METHOD_BANK_CARD', '💳 Банковская карта'),
|
||||
'yoo_money': ('PAYMENT_METHOD_YOO_MONEY', '🟣 ЮMoney'),
|
||||
'sberbank': ('PAYMENT_METHOD_SBERBANK', '🟢 СберPay'),
|
||||
'tinkoff_bank': ('PAYMENT_METHOD_TINKOFF_BANK', '🟡 Т-Банк'),
|
||||
'sbp': ('PAYMENT_METHOD_SBP', '🏦 СБП'),
|
||||
'mir_pay': ('PAYMENT_METHOD_MIR_PAY', '🟦 Mir Pay'),
|
||||
}
|
||||
|
||||
|
||||
def _get_payment_method_display_name(card, language: str = DEFAULT_LANGUAGE) -> str:
|
||||
"""Локализованное название метода оплаты + реквизиты."""
|
||||
texts = get_texts(language)
|
||||
|
||||
# Для банковских карт title уже содержит тип + маску (например "Visa *4444")
|
||||
if card.method_type == 'bank_card' or (not card.method_type and card.card_last4):
|
||||
if card.title:
|
||||
return card.title
|
||||
if card.card_last4:
|
||||
return f'{card.card_type or "Card"} *{card.card_last4}'
|
||||
|
||||
# Для остальных методов: локализованное название + реквизиты из title
|
||||
locale_entry = _PAYMENT_METHOD_LOCALE_KEYS.get(card.method_type)
|
||||
if locale_entry:
|
||||
key, default = locale_entry
|
||||
method_name = texts.t(key, default)
|
||||
else:
|
||||
method_name = card.method_type or 'Card'
|
||||
|
||||
if card.title:
|
||||
return f'{method_name} {card.title}'
|
||||
return method_name
|
||||
|
||||
|
||||
def get_saved_cards_keyboard(cards: list, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
for card in cards:
|
||||
card_label = f'🗑 {_get_payment_method_display_name(card, language)}'
|
||||
keyboard.append([InlineKeyboardButton(text=card_label, callback_data=f'unlink_card_{card.id}')])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_confirm_unlink_keyboard(card_id: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('SAVED_CARDS_CONFIRM_YES', '✅ Да, отвязать'),
|
||||
callback_data=f'confirm_unlink_{card_id}',
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CANCEL', '❌ Отмена'),
|
||||
callback_data='saved_cards_list',
|
||||
),
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
@@ -899,6 +899,20 @@
|
||||
"AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!",
|
||||
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Auto-payment completed</b>\n\nBalance topped up by {amount} for subscription renewal.",
|
||||
"RECURRENT_TOPUP_FAILED": "❌ <b>Auto-payment failed</b>\n\nCould not charge {amount} from any saved card for subscription renewal.\n\nPlease top up your balance manually to avoid service interruption.",
|
||||
"SAVED_CARDS_BUTTON": "💳 Saved cards",
|
||||
"SAVED_CARDS_TITLE": "💳 <b>Saved cards</b>\n\nSelect a card to unlink:",
|
||||
"SAVED_CARDS_EMPTY": "💳 <b>Saved cards</b>\n\nNo saved cards.\nA card will be saved automatically on your next balance top-up.",
|
||||
"SAVED_CARDS_CONFIRM_UNLINK": "Are you sure you want to unlink <b>{card}</b>?\n\nAfter unlinking, autopay won't be able to use this card.",
|
||||
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>Warning:</b> this is your last saved card. After unlinking, autopay won't be able to charge payments.",
|
||||
"SAVED_CARDS_UNLINKED": "✅ Card unlinked",
|
||||
"SAVED_CARDS_UNLINK_ERROR": "❌ Failed to unlink card",
|
||||
"SAVED_CARDS_CONFIRM_YES": "✅ Yes, unlink",
|
||||
"PAYMENT_METHOD_BANK_CARD": "💳 Bank card",
|
||||
"PAYMENT_METHOD_YOO_MONEY": "🟣 YooMoney",
|
||||
"PAYMENT_METHOD_SBERBANK": "🟢 SberPay",
|
||||
"PAYMENT_METHOD_TINKOFF_BANK": "🟡 T-Bank",
|
||||
"PAYMENT_METHOD_SBP": "🏦 SBP",
|
||||
"PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay",
|
||||
"BACK": "⬅️ Back",
|
||||
"BACK_BUTTON": "◀️ Back",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu",
|
||||
|
||||
@@ -919,6 +919,20 @@
|
||||
"AUTOPAY_TOGGLE_SUCCESS": "✅ پرداخت خودکار {status}!",
|
||||
"RECURRENT_TOPUP_SUCCESS": "✅ <b>پرداخت خودکار انجام شد</b>\n\nموجودی به مبلغ {amount} برای تمدید اشتراک شارژ شد.",
|
||||
"RECURRENT_TOPUP_FAILED": "❌ <b>پرداخت خودکار ناموفق بود</b>\n\nامکان کسر {amount} از هیچ کارت ذخیره شدهای برای تمدید اشتراک وجود نداشت.\n\nلطفاً موجودی را به صورت دستی شارژ کنید.",
|
||||
"SAVED_CARDS_BUTTON": "💳 کارتهای ذخیره شده",
|
||||
"SAVED_CARDS_TITLE": "💳 <b>کارتهای ذخیره شده</b>\n\nکارت مورد نظر برای حذف را انتخاب کنید:",
|
||||
"SAVED_CARDS_EMPTY": "💳 <b>کارتهای ذخیره شده</b>\n\nکارت ذخیره شدهای وجود ندارد.\nکارت به صورت خودکار در شارژ بعدی موجودی ذخیره میشود.",
|
||||
"SAVED_CARDS_CONFIRM_UNLINK": "آیا مطمئن هستید که میخواهید کارت <b>{card}</b> را حذف کنید؟\n\nپس از حذف، پرداخت خودکار نمیتواند از این کارت استفاده کند.",
|
||||
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>توجه:</b> این آخرین کارت ذخیره شده شماست. پس از حذف، پرداخت خودکار امکان برداشت وجه نخواهد داشت.",
|
||||
"SAVED_CARDS_UNLINKED": "✅ کارت حذف شد",
|
||||
"SAVED_CARDS_UNLINK_ERROR": "❌ حذف کارت انجام نشد",
|
||||
"SAVED_CARDS_CONFIRM_YES": "✅ بله، حذف کن",
|
||||
"PAYMENT_METHOD_BANK_CARD": "💳 کارت بانکی",
|
||||
"PAYMENT_METHOD_YOO_MONEY": "🟣 ЮMoney",
|
||||
"PAYMENT_METHOD_SBERBANK": "🟢 СберPay",
|
||||
"PAYMENT_METHOD_TINKOFF_BANK": "🟡 Т-Банк",
|
||||
"PAYMENT_METHOD_SBP": "🏦 СБП",
|
||||
"PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay",
|
||||
"BACK": "⬅️ قبلی",
|
||||
"BACK_BUTTON": "◀️ بازگشت",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ منوی اصلی",
|
||||
|
||||
@@ -919,6 +919,20 @@
|
||||
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!",
|
||||
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Автоплатёж выполнен</b>\n\nБаланс пополнен на {amount} для продления подписки.",
|
||||
"RECURRENT_TOPUP_FAILED": "❌ <b>Автоплатёж не удался</b>\n\nНе удалось списать {amount} ни с одной сохранённой карты для продления подписки.\n\nПополните баланс вручную, чтобы подписка не прервалась.",
|
||||
"SAVED_CARDS_BUTTON": "💳 Привязанные карты",
|
||||
"SAVED_CARDS_TITLE": "💳 <b>Привязанные карты</b>\n\nВыберите карту для отвязки:",
|
||||
"SAVED_CARDS_EMPTY": "💳 <b>Привязанные карты</b>\n\nНет привязанных карт.\nКарта привяжется автоматически при следующем пополнении баланса.",
|
||||
"SAVED_CARDS_CONFIRM_UNLINK": "Вы уверены, что хотите отвязать карту <b>{card}</b>?\n\nПосле отвязки автоплатеж не сможет использовать эту карту.",
|
||||
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>Внимание:</b> это ваша последняя привязанная карта. После отвязки автоплатеж не сможет списывать средства.",
|
||||
"SAVED_CARDS_UNLINKED": "✅ Карта отвязана",
|
||||
"SAVED_CARDS_UNLINK_ERROR": "❌ Не удалось отвязать карту",
|
||||
"SAVED_CARDS_CONFIRM_YES": "✅ Да, отвязать",
|
||||
"PAYMENT_METHOD_BANK_CARD": "💳 Банковская карта",
|
||||
"PAYMENT_METHOD_YOO_MONEY": "🟣 ЮMoney",
|
||||
"PAYMENT_METHOD_SBERBANK": "🟢 СберPay",
|
||||
"PAYMENT_METHOD_TINKOFF_BANK": "🟡 Т-Банк",
|
||||
"PAYMENT_METHOD_SBP": "🏦 СБП",
|
||||
"PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay",
|
||||
"BACK": "⬅️ Назад",
|
||||
"BACK_BUTTON": "◀️ Назад",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню",
|
||||
|
||||
@@ -841,6 +841,20 @@
|
||||
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатіж {status}!",
|
||||
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Автоплатіж виконано</b>\n\nБаланс поповнено на {amount} для продовження підписки.",
|
||||
"RECURRENT_TOPUP_FAILED": "❌ <b>Автоплатіж не вдався</b>\n\nНе вдалося списати {amount} з жодної збереженої картки для продовження підписки.\n\nПоповніть баланс вручну, щоб підписка не перервалася.",
|
||||
"SAVED_CARDS_BUTTON": "💳 Прив'язані картки",
|
||||
"SAVED_CARDS_TITLE": "💳 <b>Прив'язані картки</b>\n\nОберіть картку для відв'язки:",
|
||||
"SAVED_CARDS_EMPTY": "💳 <b>Прив'язані картки</b>\n\nНемає прив'язаних карток.\nКартка прив'яжеться автоматично при наступному поповненні балансу.",
|
||||
"SAVED_CARDS_CONFIRM_UNLINK": "Ви впевнені, що хочете відв'язати картку <b>{card}</b>?\n\nПісля відв'язки автоплатіж не зможе використовувати цю картку.",
|
||||
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>Увага:</b> це ваша остання прив'язана картка. Після відв'язки автоплатіж не зможе списувати кошти.",
|
||||
"SAVED_CARDS_UNLINKED": "✅ Картку відв'язано",
|
||||
"SAVED_CARDS_UNLINK_ERROR": "❌ Не вдалося відв'язати картку",
|
||||
"SAVED_CARDS_CONFIRM_YES": "✅ Так, відв'язати",
|
||||
"PAYMENT_METHOD_BANK_CARD": "💳 Банківська картка",
|
||||
"PAYMENT_METHOD_YOO_MONEY": "🟣 ЮMoney",
|
||||
"PAYMENT_METHOD_SBERBANK": "🟢 СберPay",
|
||||
"PAYMENT_METHOD_TINKOFF_BANK": "🟡 Т-Банк",
|
||||
"PAYMENT_METHOD_SBP": "🏦 СБП",
|
||||
"PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay",
|
||||
"BACK": "⬅️ Назад",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В головне меню",
|
||||
"BACK_TO_MENU": "🏠 В головне меню",
|
||||
|
||||
@@ -839,6 +839,20 @@
|
||||
"AUTOPAY_TOGGLE_SUCCESS": "✅自动支付{status}!",
|
||||
"RECURRENT_TOPUP_SUCCESS": "✅ <b>自动扣款成功</b>\n\n余额已充值{amount},用于续订订阅。",
|
||||
"RECURRENT_TOPUP_FAILED": "❌ <b>自动扣款失败</b>\n\n无法从任何已保存的银行卡中扣除{amount}以续订订阅。\n\n请手动充值余额以避免服务中断。",
|
||||
"SAVED_CARDS_BUTTON": "💳 已绑定的卡",
|
||||
"SAVED_CARDS_TITLE": "💳 <b>已绑定的卡</b>\n\n选择要解绑的卡:",
|
||||
"SAVED_CARDS_EMPTY": "💳 <b>已绑定的卡</b>\n\n没有已绑定的卡。\n下次充值余额时将自动绑定。",
|
||||
"SAVED_CARDS_CONFIRM_UNLINK": "确定要解绑 <b>{card}</b> 吗?\n\n解绑后自动扣款将无法使用此卡。",
|
||||
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>注意:</b>这是您最后一张绑定的卡。解绑后自动扣款将无法进行。",
|
||||
"SAVED_CARDS_UNLINKED": "✅ 卡已解绑",
|
||||
"SAVED_CARDS_UNLINK_ERROR": "❌ 解绑失败",
|
||||
"SAVED_CARDS_CONFIRM_YES": "✅ 确认解绑",
|
||||
"PAYMENT_METHOD_BANK_CARD": "💳 银行卡",
|
||||
"PAYMENT_METHOD_YOO_MONEY": "🟣 ЮMoney",
|
||||
"PAYMENT_METHOD_SBERBANK": "🟢 СберPay",
|
||||
"PAYMENT_METHOD_TINKOFF_BANK": "🟡 Т-Банк",
|
||||
"PAYMENT_METHOD_SBP": "🏦 СБП",
|
||||
"PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay",
|
||||
"BACK": "⬅️返回",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️返回主菜单",
|
||||
"BACK_TO_MENU": "🏠返回主菜单",
|
||||
|
||||
@@ -446,6 +446,15 @@ async def execute_merge(
|
||||
# 4. Суммируем баланс (включая отрицательный — долг не должен исчезать)
|
||||
transferred_kopeks = secondary.balance_kopeks
|
||||
if transferred_kopeks != 0:
|
||||
from app.database.models import User as UserModel
|
||||
|
||||
if isinstance(primary, UserModel):
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
primary = await lock_user_for_update(db, primary)
|
||||
secondary = await lock_user_for_update(db, secondary)
|
||||
# Re-read after lock in case concurrent payment changed it
|
||||
transferred_kopeks = secondary.balance_kopeks
|
||||
primary.balance_kopeks += transferred_kopeks
|
||||
secondary.balance_kopeks = 0
|
||||
logger.info(
|
||||
|
||||
@@ -306,6 +306,9 @@ class ContestAttemptService:
|
||||
return ''
|
||||
kopeks = int(prize_value) if prize_value.isdigit() else 0
|
||||
if kopeks > 0:
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
user.balance_kopeks += kopeks
|
||||
await db.commit()
|
||||
return texts.t('CONTEST_BALANCE_GRANTED', 'Бонус {amount} зачислен!').format(
|
||||
|
||||
@@ -17,7 +17,17 @@ from app.config import settings
|
||||
from app.database.crud.landing import create_guest_purchase
|
||||
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, replace_subscription
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage, Tariff, User
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import _get_or_create_default_promo_group
|
||||
from app.database.models import (
|
||||
GuestPurchase,
|
||||
GuestPurchaseStatus,
|
||||
LandingPage,
|
||||
PaymentMethod,
|
||||
Tariff,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
|
||||
@@ -242,7 +252,7 @@ async def fulfill_purchase(
|
||||
# Active subscription or gift with any existing subscription — hold for manual activation
|
||||
purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
|
||||
purchase.user_id = user.id
|
||||
if recipient_type == 'email' and not purchase.is_gift:
|
||||
if recipient_type == 'email' and not purchase.is_gift and is_new_account:
|
||||
purchase.auto_login_token = create_auto_login_token(user.id)
|
||||
await db.commit()
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
||||
@@ -310,12 +320,28 @@ async def fulfill_purchase(
|
||||
purchase.status = GuestPurchaseStatus.DELIVERED.value
|
||||
purchase.user_id = user.id
|
||||
purchase.delivered_at = datetime.now(UTC)
|
||||
if recipient_type == 'email' and not purchase.is_gift:
|
||||
if recipient_type == 'email' and not purchase.is_gift and is_new_account:
|
||||
purchase.auto_login_token = create_auto_login_token(user.id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
||||
|
||||
# Create transaction so promo group auto-assignment and contest tracking work
|
||||
try:
|
||||
payment_method_enum = _resolve_payment_method(purchase.payment_method)
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=purchase.amount_kopeks,
|
||||
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
|
||||
payment_method=payment_method_enum,
|
||||
external_id=purchase.payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create transaction for guest purchase', purchase_id=purchase.id)
|
||||
|
||||
try:
|
||||
await send_guest_notification(
|
||||
purchase,
|
||||
@@ -357,6 +383,26 @@ async def fulfill_purchase(
|
||||
return purchase
|
||||
|
||||
|
||||
def _resolve_payment_method(method_str: str | None) -> PaymentMethod | None:
|
||||
"""Convert payment method string from GuestPurchase to PaymentMethod enum."""
|
||||
if not method_str:
|
||||
return None
|
||||
# Try exact match first (handles 'telegram_stars', 'kassa_ai', 'yookassa', etc.)
|
||||
try:
|
||||
return PaymentMethod(method_str)
|
||||
except ValueError:
|
||||
pass
|
||||
# Strip sub-option suffix ('yookassa_sbp' → 'yookassa', 'platega_2' → 'platega')
|
||||
if '_' in method_str:
|
||||
base_method = method_str.split('_')[0]
|
||||
try:
|
||||
return PaymentMethod(base_method)
|
||||
except ValueError:
|
||||
pass
|
||||
logger.debug('Unknown payment method for transaction', method=method_str)
|
||||
return None
|
||||
|
||||
|
||||
def _mask_email(email: str) -> str:
|
||||
"""Mask email for logging: 'user@example.com' -> 'u***@e***.com'."""
|
||||
if not email:
|
||||
@@ -399,8 +445,8 @@ async def _find_or_create_user(
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
is_new_account = False
|
||||
# Existing user WITHOUT password — generate one and set up cabinet access
|
||||
if not user.password_hash:
|
||||
# User without cabinet access — generate credentials
|
||||
plain_password = secrets.token_urlsafe(12)
|
||||
user.password_hash = hash_password(plain_password)
|
||||
if purchase:
|
||||
@@ -410,16 +456,22 @@ async def _find_or_create_user(
|
||||
if not user.email_verified:
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
# Ensure default promo group
|
||||
if not user.promo_group_id:
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user.promo_group_id = default_group.id
|
||||
return user, is_new_account
|
||||
|
||||
# Create new email user with verified cabinet account
|
||||
plain_password = secrets.token_urlsafe(12)
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user = User(
|
||||
auth_type='email',
|
||||
email=contact_value,
|
||||
email_verified=True,
|
||||
email_verified_at=datetime.now(UTC),
|
||||
password_hash=hash_password(plain_password),
|
||||
promo_group_id=default_group.id,
|
||||
)
|
||||
if purchase:
|
||||
purchase.cabinet_password = plain_password
|
||||
@@ -431,7 +483,7 @@ async def _find_or_create_user(
|
||||
result = await db.execute(select(User).where(User.email == contact_value))
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
# Clear stale password from failed insert, then check if re-fetched user needs one
|
||||
# Race condition — user was created concurrently
|
||||
if purchase:
|
||||
purchase.cabinet_password = None
|
||||
is_new_account = False
|
||||
@@ -444,6 +496,9 @@ async def _find_or_create_user(
|
||||
if not user.email_verified:
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
if not user.promo_group_id:
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user.promo_group_id = default_group.id
|
||||
return user, is_new_account
|
||||
raise
|
||||
logger.info(
|
||||
@@ -508,13 +563,19 @@ async def _find_or_create_user(
|
||||
resolved_telegram_id=resolved_telegram_id,
|
||||
)
|
||||
await db.refresh(user)
|
||||
# Ensure default promo group
|
||||
if not user.promo_group_id:
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user.promo_group_id = default_group.id
|
||||
return user, False
|
||||
|
||||
# Create new telegram user
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user = User(
|
||||
auth_type='telegram',
|
||||
username=username,
|
||||
telegram_id=resolved_telegram_id,
|
||||
promo_group_id=default_group.id,
|
||||
)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
@@ -525,10 +586,16 @@ async def _find_or_create_user(
|
||||
result = await db.execute(select(User).where(User.telegram_id == resolved_telegram_id))
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
if not user.promo_group_id:
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user.promo_group_id = default_group.id
|
||||
return user, False
|
||||
result = await db.execute(select(User).where(func.lower(User.username) == normalized))
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
if not user.promo_group_id:
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user.promo_group_id = default_group.id
|
||||
return user, False
|
||||
raise
|
||||
logger.info(
|
||||
@@ -854,13 +921,29 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
||||
purchase.subscription_crypto_link = subscription.subscription_crypto_link
|
||||
purchase.status = GuestPurchaseStatus.DELIVERED.value
|
||||
purchase.delivered_at = datetime.now(UTC)
|
||||
if user.auth_type == 'email' and not purchase.is_gift:
|
||||
if user.auth_type == 'email' and not purchase.is_gift and is_new_account:
|
||||
purchase.auto_login_token = create_auto_login_token(user.id)
|
||||
|
||||
# Single atomic commit: subscription + purchase status + user changes
|
||||
await db.commit()
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
||||
|
||||
# Create transaction so promo group auto-assignment and contest tracking work
|
||||
try:
|
||||
payment_method_enum = _resolve_payment_method(purchase.payment_method)
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=purchase.amount_kopeks,
|
||||
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
|
||||
payment_method=payment_method_enum,
|
||||
external_id=purchase.payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create transaction for activated purchase', purchase_id=purchase.id)
|
||||
|
||||
if not skip_notification:
|
||||
try:
|
||||
await send_guest_notification(
|
||||
|
||||
@@ -1053,12 +1053,15 @@ class MonitoringService:
|
||||
autopay_period = 30
|
||||
|
||||
try:
|
||||
renewal_cost = await self.subscription_service.calculate_renewal_price(
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
subscription,
|
||||
autopay_period,
|
||||
db,
|
||||
user=user,
|
||||
)
|
||||
renewal_cost = pricing.final_total
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка расчёта стоимости автопродления, пропускаем',
|
||||
|
||||
@@ -271,6 +271,11 @@ class CloudPaymentsPaymentMixin:
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.pricing_engine import RenewalPricing, pricing_engine
|
||||
from app.services.subscription_renewal_service import (
|
||||
RenewalPaymentDescriptor,
|
||||
SubscriptionRenewalChargeError,
|
||||
@@ -163,7 +164,13 @@ class CryptoBotPaymentMixin:
|
||||
else:
|
||||
paid_at = datetime.now(UTC)
|
||||
|
||||
updated_payment = await cryptobot_crud.update_cryptobot_payment_status(db, invoice_id, status, paid_at)
|
||||
updated_payment = await cryptobot_crud.update_cryptobot_payment_status(
|
||||
db,
|
||||
invoice_id,
|
||||
status,
|
||||
paid_at,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
descriptor = decode_payment_payload(
|
||||
getattr(updated_payment, 'payload', '') or '',
|
||||
@@ -290,6 +297,11 @@ class CryptoBotPaymentMixin:
|
||||
logger.error('Пользователь с ID не найден при пополнении баланса', user_id=updated_payment.user_id)
|
||||
return False
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
@@ -404,7 +416,7 @@ class CryptoBotPaymentMixin:
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Не удалось загрузить пользователя для продления через CryptoBot',
|
||||
getattr=getattr(payment, 'user_id', None),
|
||||
payment_user_id=getattr(payment, 'user_id', None),
|
||||
error=error,
|
||||
)
|
||||
return False
|
||||
@@ -412,7 +424,7 @@ class CryptoBotPaymentMixin:
|
||||
if not user:
|
||||
logger.error(
|
||||
'Пользователь не найден при обработке продления через CryptoBot',
|
||||
getattr=getattr(payment, 'user_id', None),
|
||||
payment_user_id=getattr(payment, 'user_id', None),
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -420,12 +432,27 @@ class CryptoBotPaymentMixin:
|
||||
if not subscription or subscription.id != descriptor.subscription_id:
|
||||
logger.warning(
|
||||
'Продление через CryptoBot отклонено: подписка не совпадает с ожидаемой',
|
||||
getattr=getattr(subscription, 'id', None),
|
||||
subscription_id=descriptor.subscription_id,
|
||||
current_subscription_id=getattr(subscription, 'id', None),
|
||||
expected_subscription_id=descriptor.subscription_id,
|
||||
)
|
||||
return False
|
||||
|
||||
pricing_model: SubscriptionRenewalPricing | None = None
|
||||
# Validate period_days against allowed periods
|
||||
tariff = getattr(subscription, 'tariff', None)
|
||||
if tariff and tariff.period_prices:
|
||||
allowed_periods = [int(p) for p in tariff.period_prices.keys()]
|
||||
else:
|
||||
allowed_periods = settings.get_available_renewal_periods()
|
||||
if descriptor.period_days not in allowed_periods:
|
||||
logger.error(
|
||||
'CryptoBot renewal rejected: period_days not in allowed periods',
|
||||
invoice_id=payment.invoice_id,
|
||||
period_days=descriptor.period_days,
|
||||
allowed_periods=allowed_periods,
|
||||
)
|
||||
return False
|
||||
|
||||
pricing_model: SubscriptionRenewalPricing | RenewalPricing | None = None
|
||||
if descriptor.pricing_snapshot:
|
||||
try:
|
||||
pricing_model = SubscriptionRenewalPricing.from_payload(descriptor.pricing_snapshot)
|
||||
@@ -438,11 +465,11 @@ class CryptoBotPaymentMixin:
|
||||
|
||||
if pricing_model is None:
|
||||
try:
|
||||
pricing_model = await renewal_service.calculate_pricing(
|
||||
pricing_model = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
descriptor.period_days,
|
||||
user=user,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
@@ -454,27 +481,40 @@ class CryptoBotPaymentMixin:
|
||||
|
||||
if pricing_model.final_total != descriptor.total_amount_kopeks:
|
||||
logger.warning(
|
||||
'Сумма продления через CryptoBot изменилась (ожидалось , получено)',
|
||||
'Сумма продления через CryptoBot изменилась',
|
||||
invoice_id=payment.invoice_id,
|
||||
total_amount_kopeks=descriptor.total_amount_kopeks,
|
||||
final_total=pricing_model.final_total,
|
||||
expected_kopeks=descriptor.total_amount_kopeks,
|
||||
actual_kopeks=pricing_model.final_total,
|
||||
)
|
||||
pricing_model.final_total = descriptor.total_amount_kopeks
|
||||
pricing_model.per_month = (
|
||||
descriptor.total_amount_kopeks // pricing_model.months
|
||||
if pricing_model.months
|
||||
else descriptor.total_amount_kopeks
|
||||
if pricing_model.final_total > descriptor.total_amount_kopeks:
|
||||
# Price increased since invoice creation — user would be undercharged.
|
||||
# Reject and let the user create a new invoice at the current price.
|
||||
logger.error(
|
||||
'CryptoBot renewal rejected: recalculated price exceeds agreed amount',
|
||||
invoice_id=payment.invoice_id,
|
||||
agreed_kopeks=descriptor.total_amount_kopeks,
|
||||
recalculated_kopeks=pricing_model.final_total,
|
||||
)
|
||||
return False
|
||||
# Price decreased — charge recalculated (lower) amount, user benefits
|
||||
logger.info(
|
||||
'CryptoBot renewal: price decreased, user benefits',
|
||||
invoice_id=payment.invoice_id,
|
||||
agreed_kopeks=descriptor.total_amount_kopeks,
|
||||
recalculated_kopeks=pricing_model.final_total,
|
||||
delta_kopeks=descriptor.total_amount_kopeks - pricing_model.final_total,
|
||||
)
|
||||
|
||||
pricing_model.period_days = descriptor.period_days
|
||||
pricing_model.period_id = build_renewal_period_id(descriptor.period_days)
|
||||
# Override period_days/period_id only on mutable SubscriptionRenewalPricing
|
||||
if isinstance(pricing_model, SubscriptionRenewalPricing):
|
||||
pricing_model.period_days = descriptor.period_days
|
||||
pricing_model.period_id = build_renewal_period_id(descriptor.period_days)
|
||||
|
||||
# When price drops, recalculate balance portion: total minus the fixed external payment
|
||||
# This ensures the user isn't overcharged from balance when crypto already covers more
|
||||
required_balance = max(
|
||||
0,
|
||||
min(
|
||||
pricing_model.final_total,
|
||||
descriptor.balance_component_kopeks,
|
||||
),
|
||||
pricing_model.final_total - descriptor.missing_amount_kopeks,
|
||||
)
|
||||
|
||||
current_balance = getattr(user, 'balance_kopeks', 0)
|
||||
@@ -606,10 +646,10 @@ class CryptoBotPaymentMixin:
|
||||
reply_markup=payload.reply_markup,
|
||||
)
|
||||
logger.info(
|
||||
'✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)',
|
||||
payload.telegram_id,
|
||||
f'{payload.amount_rubles:.2f}',
|
||||
payload.asset,
|
||||
'Отправлено уведомление пользователю о пополнении',
|
||||
telegram_id=payload.telegram_id,
|
||||
amount_rubles=f'{payload.amount_rubles:.2f}',
|
||||
asset=payload.asset,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления о пополнении CryptoBot', error=error)
|
||||
|
||||
@@ -307,6 +307,11 @@ class FreekassaPaymentMixin:
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
|
||||
@@ -352,6 +352,11 @@ class HeleketPaymentMixin:
|
||||
logger.error('Пользователь не найден для Heleket платежа', user_id=updated_payment.user_id)
|
||||
return None
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
|
||||
@@ -295,6 +295,11 @@ class KassaAiPaymentMixin:
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
|
||||
@@ -283,6 +283,11 @@ class MulenPayPaymentMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
|
||||
@@ -396,6 +396,11 @@ class Pal24PaymentMixin:
|
||||
|
||||
await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id)
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
|
||||
@@ -402,6 +402,11 @@ class PlategaPaymentMixin:
|
||||
logger.info('Platega платеж уже зачислил баланс ранее', correlation_id=payment.correlation_id)
|
||||
return payment
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
|
||||
@@ -384,6 +384,11 @@ class TelegramStarsMixin:
|
||||
) -> bool:
|
||||
"""Начисляет баланс пользователю после оплаты Stars и запускает автопокупку."""
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
# Запоминаем старые значения, чтобы корректно построить уведомления.
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
@@ -482,6 +482,11 @@ class WataPaymentMixin:
|
||||
|
||||
await payment_module.link_wata_payment_to_transaction(db, payment, transaction.id)
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.database.models import Transaction, YooKassaPayment
|
||||
from app.database.models import Transaction, User, YooKassaPayment
|
||||
|
||||
_INT32_MAX = 2_147_483_647
|
||||
|
||||
|
||||
class YooKassaPaymentMixin:
|
||||
@@ -617,6 +619,7 @@ class YooKassaPaymentMixin:
|
||||
external_id=payment.yookassa_payment_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
|
||||
if not getattr(payment, 'transaction_id', None):
|
||||
@@ -740,8 +743,13 @@ class YooKassaPaymentMixin:
|
||||
'Ошибка реферального начисления при покупке подписки YooKassa', ref_error=ref_error
|
||||
)
|
||||
else:
|
||||
old_balance = getattr(user, 'balance_kopeks', 0)
|
||||
was_first_topup = not getattr(user, 'has_made_first_topup', False)
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
@@ -783,6 +791,22 @@ class YooKassaPaymentMixin:
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
try:
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=transaction_type,
|
||||
payment_method=PaymentMethod.YOOKASSA,
|
||||
external_id=payment.yookassa_payment_id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Failed to emit YooKassa transaction side effects', error=error)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
@@ -1140,11 +1164,18 @@ class YooKassaPaymentMixin:
|
||||
expiry_year = str(raw_year) if raw_year is not None else None
|
||||
method_type = pm.get('type', 'bank_card')
|
||||
|
||||
# Формируем название
|
||||
# Формируем title — только реквизиты без названия метода
|
||||
# (локализованное название подставляется в UI через _get_payment_method_display_name)
|
||||
title = None
|
||||
if card_last4:
|
||||
type_label = card_type or 'Card'
|
||||
title = f'{type_label} *{card_last4}'
|
||||
elif method_type != 'bank_card':
|
||||
# Для не-карточных методов: yoo_money (account_number), sbp/sberbank (phone) и т.д.
|
||||
account = pm.get('account_number') or pm.get('phone')
|
||||
if account:
|
||||
masked = account[-4:] if len(account) >= 4 else account
|
||||
title = f'*{masked}'
|
||||
|
||||
saved = await create_saved_payment_method(
|
||||
db=db,
|
||||
@@ -1334,7 +1365,9 @@ class YooKassaPaymentMixin:
|
||||
return None
|
||||
|
||||
metadata = self._normalise_yookassa_metadata(event_object.get('metadata'))
|
||||
user_id_raw = metadata.get('user_id') or metadata.get('userId')
|
||||
user_id_raw = metadata.get('user_id')
|
||||
if user_id_raw is None:
|
||||
user_id_raw = metadata.get('userId')
|
||||
|
||||
if user_id_raw is None:
|
||||
logger.error(
|
||||
@@ -1353,21 +1386,79 @@ class YooKassaPaymentMixin:
|
||||
)
|
||||
return None
|
||||
|
||||
# Verify user exists before creating FK-linked record
|
||||
try:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
if user_id <= 0:
|
||||
logger.error(
|
||||
'Webhook YooKassa содержит неположительный user_id',
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Verify user exists before creating FK-linked record.
|
||||
# Legacy payments may have telegram_id stored in metadata['user_id']
|
||||
# instead of the internal User.id. Detect by checking int32 range.
|
||||
user: User | None = None
|
||||
|
||||
try:
|
||||
from app.database.crud.user import get_user_by_id, get_user_by_telegram_id
|
||||
|
||||
if user_id <= _INT32_MAX:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
# Cross-validate: if metadata also has telegram_id, verify it matches
|
||||
if user:
|
||||
meta_tg = metadata.get('user_telegram_id') or metadata.get('userTelegramId')
|
||||
if meta_tg is not None:
|
||||
try:
|
||||
expected_tg = int(meta_tg)
|
||||
except (TypeError, ValueError):
|
||||
expected_tg = None
|
||||
if expected_tg and user.telegram_id != expected_tg:
|
||||
logger.warning(
|
||||
'Webhook YooKassa: user_id совпал, но telegram_id не совпадает — '
|
||||
'вероятно legacy metadata, ищем по telegram_id',
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
user_id=user_id,
|
||||
user_telegram_id=user.telegram_id,
|
||||
expected_telegram_id=expected_tg,
|
||||
)
|
||||
user = await get_user_by_telegram_id(db, expected_tg)
|
||||
else:
|
||||
# user_id exceeds int32 — это telegram_id из legacy-платежа
|
||||
logger.warning(
|
||||
'Webhook YooKassa: metadata[user_id] превышает int32, ищем как telegram_id',
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
suspected_telegram_id=user_id,
|
||||
)
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
|
||||
# Fallback: try user_telegram_id from metadata if primary lookup failed
|
||||
if not user:
|
||||
tg_id_raw = metadata.get('user_telegram_id')
|
||||
if tg_id_raw is None:
|
||||
tg_id_raw = metadata.get('userTelegramId')
|
||||
if tg_id_raw is not None:
|
||||
try:
|
||||
tg_id = int(tg_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
tg_id = None
|
||||
if tg_id and tg_id > 0:
|
||||
user = await get_user_by_telegram_id(db, tg_id)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
logger.warning(
|
||||
'Webhook YooKassa : user_id= не найден в БД, пропускаем восстановление платежа',
|
||||
'Webhook YooKassa: пользователь не найден, пропускаем восстановление платежа',
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
user_id=user_id,
|
||||
user_telegram_id=metadata.get('user_telegram_id'),
|
||||
)
|
||||
return None
|
||||
|
||||
# Use the resolved internal ID for the FK column
|
||||
user_id = user.id
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'Webhook YooKassa : не удалось проверить user_id',
|
||||
'Webhook YooKassa: не удалось проверить user_id',
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
user_id=user_id,
|
||||
e=e,
|
||||
|
||||
@@ -686,6 +686,53 @@ class PaymentService(
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Telegram Stars ---------------------------------------------------
|
||||
if payment_method == 'telegram_stars':
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
logger.warning('Telegram Stars is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
if self.bot is None:
|
||||
logger.warning('Bot instance required for Stars guest payment')
|
||||
return None
|
||||
|
||||
from aiogram.types import LabeledPrice
|
||||
|
||||
rate = settings.get_stars_rate()
|
||||
if rate <= 0:
|
||||
logger.error('TELEGRAM_STARS_RATE_RUB is not positive, cannot create Stars invoice')
|
||||
return None
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = max(1, round(amount_rubles / rate))
|
||||
|
||||
payload = f'guest_purchase_{purchase_token}'
|
||||
|
||||
try:
|
||||
invoice_url = await self.bot.create_invoice_link(
|
||||
title='Подарочная подписка VPN',
|
||||
description=f'{description} ({stars_amount} ⭐)',
|
||||
payload=payload,
|
||||
provider_token='',
|
||||
currency='XTR',
|
||||
prices=[LabeledPrice(label='Подарочная подписка', amount=stars_amount)],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Created Stars invoice for guest purchase',
|
||||
stars_amount=stars_amount,
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
)
|
||||
return {
|
||||
'payment_url': invoice_url,
|
||||
'payment_id': f'stars_{purchase_token[:12]}',
|
||||
'provider': 'telegram_stars',
|
||||
}
|
||||
|
||||
except Exception as stars_error:
|
||||
logger.error('Error creating Stars invoice for guest payment', error=stars_error)
|
||||
return None
|
||||
|
||||
# --- Unsupported provider ---------------------------------------------
|
||||
logger.warning(
|
||||
'Guest payment requested for unsupported provider',
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.config import CLASSIC_PERIOD_PRICES, PERIOD_PRICES, settings
|
||||
from app.database.crud.server_squad import get_server_squads_by_uuids
|
||||
from app.utils.pricing_utils import calculate_months_from_days
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Subscription, User
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TariffBreakdown:
|
||||
"""Typed breakdown for tariff mode pricing."""
|
||||
|
||||
tariff_id: int
|
||||
extra_devices: int
|
||||
group_discount_pct: int
|
||||
offer_discount_pct: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClassicBreakdown:
|
||||
"""Typed breakdown for classic mode pricing."""
|
||||
|
||||
months_in_period: int
|
||||
servers: list[dict[str, Any]]
|
||||
servers_individual_prices: list[int]
|
||||
server_ids: list[int]
|
||||
base_traffic_gb: int
|
||||
purchased_traffic_gb: int
|
||||
extra_devices: int
|
||||
# NB: dict[str, int] per-category (period/servers/traffic/devices), unlike TariffBreakdown's single int
|
||||
group_discount_pct: dict[str, int]
|
||||
offer_discount_pct: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenewalPricing:
|
||||
"""Immutable result of a renewal price calculation."""
|
||||
|
||||
base_price: int # kopeks
|
||||
servers_price: int # kopeks
|
||||
traffic_price: int # kopeks
|
||||
devices_price: int # kopeks
|
||||
promo_group_discount: int # kopeks deducted
|
||||
promo_offer_discount: int # kopeks deducted
|
||||
final_total: int # kopeks — amount to charge
|
||||
period_days: int
|
||||
is_tariff_mode: bool
|
||||
breakdown: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def original_total(self) -> int:
|
||||
"""Price before all discounts (group + offer)."""
|
||||
return self.final_total + self.promo_group_discount + self.promo_offer_discount
|
||||
|
||||
|
||||
class PricingEngine:
|
||||
"""Unified pricing engine for all subscription renewal calculations."""
|
||||
|
||||
@staticmethod
|
||||
def apply_discount(amount_kopeks: int, percent: int) -> int:
|
||||
"""Apply percentage discount with integer arithmetic.
|
||||
Clamps percent to [0, 100]. Uses floor division."""
|
||||
percent = max(0, min(100, percent))
|
||||
discount = amount_kopeks * percent // 100
|
||||
return amount_kopeks - discount
|
||||
|
||||
@staticmethod
|
||||
def apply_stacked_discounts(
|
||||
amount: int,
|
||||
group_percent: int,
|
||||
offer_percent: int,
|
||||
) -> tuple[int, int, int]:
|
||||
"""Apply promo-group discount, then promo-offer discount sequentially.
|
||||
Returns (final_amount, group_discount_value, offer_discount_value)."""
|
||||
after_group = PricingEngine.apply_discount(amount, group_percent)
|
||||
group_discount_value = amount - after_group
|
||||
after_offer = PricingEngine.apply_discount(after_group, offer_percent)
|
||||
offer_discount_value = after_group - after_offer
|
||||
return after_offer, group_discount_value, offer_discount_value
|
||||
|
||||
async def _calculate_servers_price(
|
||||
self,
|
||||
country_uuids: list[str],
|
||||
db: AsyncSession,
|
||||
*,
|
||||
promo_group_id: int | None = None,
|
||||
) -> tuple[int, list[dict]]:
|
||||
"""Calculate total server price from connected squad UUIDs.
|
||||
|
||||
Uses a single batch query instead of N+1 individual queries.
|
||||
ALWAYS uses real price_kopeks even when server is unavailable
|
||||
or full. Only orphaned UUIDs (not found in DB) get price=0.
|
||||
"""
|
||||
if not country_uuids:
|
||||
return 0, []
|
||||
|
||||
try:
|
||||
servers = await get_server_squads_by_uuids(db, country_uuids)
|
||||
except Exception as e: # intentional broad catch: pricing must not crash on DB errors, servers_price=0 is safe (user pays less)
|
||||
logger.error('Ошибка пакетной загрузки серверов', error=str(e), squad_uuids=country_uuids)
|
||||
return 0, [{'uuid': uuid, 'id': None, 'price': 0, 'status': 'error'} for uuid in country_uuids]
|
||||
|
||||
server_map = {s.squad_uuid: s for s in servers}
|
||||
|
||||
total_price = 0
|
||||
details: list[dict] = []
|
||||
|
||||
for uuid in country_uuids:
|
||||
server = server_map.get(uuid)
|
||||
if server is None:
|
||||
logger.error('Сервер не найден в БД', squad_uuid=uuid)
|
||||
details.append({'uuid': uuid, 'id': None, 'price': 0, 'status': 'not_found'})
|
||||
continue
|
||||
|
||||
price = server.price_kopeks or 0
|
||||
status = 'available'
|
||||
|
||||
if not server.is_available:
|
||||
status = 'unavailable'
|
||||
logger.warning(
|
||||
'Сервер недоступен, используем реальную цену',
|
||||
squad_uuid=uuid,
|
||||
price_kopeks=price,
|
||||
)
|
||||
elif server.is_full:
|
||||
status = 'full'
|
||||
logger.warning(
|
||||
'Сервер переполнен, используем реальную цену',
|
||||
squad_uuid=uuid,
|
||||
price_kopeks=price,
|
||||
)
|
||||
elif promo_group_id is not None:
|
||||
allowed_ids = [pg.id for pg in (server.allowed_promo_groups or [])]
|
||||
if allowed_ids and promo_group_id not in allowed_ids:
|
||||
status = 'not_allowed'
|
||||
logger.warning(
|
||||
'Сервер недоступен для промогруппы, используем реальную цену',
|
||||
squad_uuid=uuid,
|
||||
promo_group_id=promo_group_id,
|
||||
price_kopeks=price,
|
||||
)
|
||||
|
||||
total_price += price
|
||||
details.append({'uuid': uuid, 'id': server.id, 'price': price, 'status': status})
|
||||
|
||||
return total_price, details
|
||||
|
||||
def _calculate_traffic_price(
|
||||
self,
|
||||
traffic_limit_gb: int,
|
||||
purchased_traffic_gb: int,
|
||||
) -> int:
|
||||
"""Calculate traffic price, separating base from purchased GB.
|
||||
Prevents purchased top-ups from inflating the tier lookup."""
|
||||
total_gb = traffic_limit_gb or 0
|
||||
purchased_gb = purchased_traffic_gb or 0
|
||||
base_gb = max(0, total_gb - purchased_gb)
|
||||
|
||||
base_price = settings.get_traffic_price(base_gb) if base_gb > 0 else 0
|
||||
purchased_price = settings.get_traffic_price(purchased_gb) if purchased_gb > 0 else 0
|
||||
|
||||
return base_price + purchased_price
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main public method
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def calculate_renewal_price(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
*,
|
||||
user: User | None = None,
|
||||
) -> RenewalPricing:
|
||||
"""Calculate renewal price for a subscription.
|
||||
|
||||
Routes to tariff mode (subscription has a tariff) or classic mode
|
||||
(legacy env-based pricing). Stacked discounts (promo-group then
|
||||
promo-offer) are applied in both modes.
|
||||
"""
|
||||
if not isinstance(period_days, int) or period_days <= 0:
|
||||
raise ValueError(f'Invalid period_days: {period_days}')
|
||||
|
||||
if subscription.tariff_id is not None:
|
||||
if subscription.tariff is None:
|
||||
logger.error(
|
||||
'tariff_id set but tariff relationship not loaded, falling back to classic mode',
|
||||
subscription_id=getattr(subscription, 'id', None),
|
||||
tariff_id=subscription.tariff_id,
|
||||
)
|
||||
else:
|
||||
return await self._calculate_tariff_mode(db, subscription, period_days, user=user)
|
||||
return await self._calculate_classic_mode(db, subscription, period_days, user=user)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tariff mode
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _calculate_tariff_mode(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
*,
|
||||
user: User | None = None,
|
||||
) -> RenewalPricing:
|
||||
"""Price calculation when subscription is linked to a Tariff."""
|
||||
tariff = subscription.tariff
|
||||
period_prices: dict = tariff.period_prices or {}
|
||||
base_price = int(period_prices.get(str(period_days), 0) or 0)
|
||||
|
||||
# Extra devices above the tariff's included limit
|
||||
device_price_per_unit = (
|
||||
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
extra_devices = max(0, (subscription.device_limit or 0) - (tariff.device_limit or 0))
|
||||
devices_price = extra_devices * device_price_per_unit
|
||||
|
||||
subtotal = base_price + devices_price
|
||||
|
||||
# Resolve discounts
|
||||
group_pct = 0
|
||||
if user and getattr(user, 'promo_group', None) is not None:
|
||||
group_pct = user.promo_group.get_discount_percent('period', period_days)
|
||||
|
||||
offer_pct = get_user_active_promo_discount_percent(user) if user else 0
|
||||
|
||||
final_total, group_discount, offer_discount = self.apply_stacked_discounts(
|
||||
subtotal,
|
||||
group_pct,
|
||||
offer_pct,
|
||||
)
|
||||
|
||||
breakdown = dataclasses.asdict(
|
||||
TariffBreakdown(
|
||||
tariff_id=tariff.id,
|
||||
extra_devices=extra_devices,
|
||||
group_discount_pct=group_pct,
|
||||
offer_discount_pct=offer_pct,
|
||||
)
|
||||
)
|
||||
|
||||
if final_total < 0:
|
||||
logger.warning(
|
||||
'Negative final_total in tariff mode, clamping to 0',
|
||||
final_total=final_total,
|
||||
subtotal=subtotal,
|
||||
group_pct=group_pct,
|
||||
offer_pct=offer_pct,
|
||||
)
|
||||
|
||||
return RenewalPricing(
|
||||
base_price=base_price,
|
||||
servers_price=0,
|
||||
traffic_price=0,
|
||||
devices_price=devices_price,
|
||||
promo_group_discount=group_discount,
|
||||
promo_offer_discount=offer_discount,
|
||||
final_total=max(0, final_total),
|
||||
period_days=period_days,
|
||||
is_tariff_mode=True,
|
||||
breakdown=breakdown,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Classic mode
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _calculate_classic_mode(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
*,
|
||||
user: User | None = None,
|
||||
) -> RenewalPricing:
|
||||
"""Price calculation for legacy (non-tariff) subscriptions.
|
||||
|
||||
Uses CLASSIC_PERIOD_PRICES from settings, falling back to the
|
||||
global PERIOD_PRICES dict during migration.
|
||||
|
||||
Per-category discounts (period, servers, traffic, devices) are
|
||||
applied separately to each component. Servers, traffic, and
|
||||
devices are monthly prices multiplied by months_in_period.
|
||||
"""
|
||||
months = calculate_months_from_days(period_days)
|
||||
|
||||
# --- Base period price (already includes full period) ---
|
||||
base_price_original = CLASSIC_PERIOD_PRICES.get(period_days)
|
||||
if base_price_original is None:
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
if base_price_original > 0:
|
||||
logger.warning(
|
||||
'CLASSIC_PERIOD_PRICES miss, falling back to PERIOD_PRICES — verify price is not from tariff regime',
|
||||
period_days=period_days,
|
||||
fallback_price_kopeks=base_price_original,
|
||||
)
|
||||
|
||||
# --- Per-category discount percents ---
|
||||
period_pct = 0
|
||||
servers_pct = 0
|
||||
traffic_pct = 0
|
||||
devices_pct = 0
|
||||
promo_group = None
|
||||
if user and getattr(user, 'promo_group', None) is not None:
|
||||
promo_group = user.promo_group
|
||||
period_pct = promo_group.get_discount_percent('period', period_days)
|
||||
servers_pct = promo_group.get_discount_percent('servers', period_days)
|
||||
traffic_pct = promo_group.get_discount_percent('traffic', period_days)
|
||||
devices_pct = promo_group.get_discount_percent('devices', period_days)
|
||||
|
||||
offer_pct = get_user_active_promo_discount_percent(user) if user else 0
|
||||
|
||||
# --- Base price with period discount ---
|
||||
base_price = self.apply_discount(base_price_original, period_pct)
|
||||
|
||||
# --- Servers (monthly × months, with servers discount) ---
|
||||
connected_squads: list[str] = subscription.connected_squads or []
|
||||
promo_group_id = getattr(user, 'promo_group_id', None) if user else None
|
||||
servers_price_per_month, server_details = await self._calculate_servers_price(
|
||||
connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
discounted_servers_per_month = self.apply_discount(servers_price_per_month, servers_pct)
|
||||
servers_price = discounted_servers_per_month * months
|
||||
|
||||
# --- Traffic (monthly × months, with traffic discount) ---
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_limit_gb = settings.get_fixed_traffic_limit()
|
||||
purchased_traffic_gb = 0
|
||||
else:
|
||||
traffic_limit_gb = (
|
||||
subscription.traffic_limit_gb
|
||||
if subscription.traffic_limit_gb is not None
|
||||
else settings.DEFAULT_TRAFFIC_LIMIT_GB
|
||||
)
|
||||
purchased_traffic_gb = subscription.purchased_traffic_gb or 0
|
||||
traffic_price_per_month = self._calculate_traffic_price(traffic_limit_gb, purchased_traffic_gb)
|
||||
discounted_traffic_per_month = self.apply_discount(traffic_price_per_month, traffic_pct)
|
||||
traffic_price = discounted_traffic_per_month * months
|
||||
|
||||
# --- Devices (monthly × months, with devices discount) ---
|
||||
default_device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
device_price_per_unit = settings.PRICE_PER_DEVICE
|
||||
extra_devices = max(0, (subscription.device_limit or 0) - default_device_limit)
|
||||
devices_price_per_month = extra_devices * device_price_per_unit
|
||||
discounted_devices_per_month = self.apply_discount(devices_price_per_month, devices_pct)
|
||||
devices_price = discounted_devices_per_month * months
|
||||
|
||||
# --- Subtotal (category discounts already applied) ---
|
||||
subtotal = base_price + servers_price + traffic_price + devices_price
|
||||
|
||||
# --- Promo offer discount on entire subtotal ---
|
||||
after_offer = self.apply_discount(subtotal, offer_pct)
|
||||
promo_offer_discount = subtotal - after_offer
|
||||
final_total = after_offer
|
||||
|
||||
# Total group discount = sum of per-category discounts
|
||||
base_group_discount = base_price_original - base_price
|
||||
servers_group_discount = (servers_price_per_month - discounted_servers_per_month) * months
|
||||
traffic_group_discount = (traffic_price_per_month - discounted_traffic_per_month) * months
|
||||
devices_group_discount = (devices_price_per_month - discounted_devices_per_month) * months
|
||||
total_group_discount = (
|
||||
base_group_discount + servers_group_discount + traffic_group_discount + devices_group_discount
|
||||
)
|
||||
|
||||
valid_servers = [d for d in server_details if d.get('id') is not None]
|
||||
breakdown = dataclasses.asdict(
|
||||
ClassicBreakdown(
|
||||
months_in_period=months,
|
||||
servers=server_details,
|
||||
servers_individual_prices=[d['price'] * months for d in valid_servers],
|
||||
server_ids=[d['id'] for d in valid_servers],
|
||||
base_traffic_gb=max(0, traffic_limit_gb - purchased_traffic_gb),
|
||||
purchased_traffic_gb=purchased_traffic_gb,
|
||||
extra_devices=extra_devices,
|
||||
group_discount_pct={
|
||||
'period': period_pct,
|
||||
'servers': servers_pct,
|
||||
'traffic': traffic_pct,
|
||||
'devices': devices_pct,
|
||||
},
|
||||
offer_discount_pct=offer_pct,
|
||||
)
|
||||
)
|
||||
|
||||
if final_total < 0:
|
||||
logger.warning(
|
||||
'Negative final_total in classic mode, clamping to 0',
|
||||
final_total=final_total,
|
||||
subtotal=subtotal,
|
||||
offer_pct=offer_pct,
|
||||
)
|
||||
|
||||
return RenewalPricing(
|
||||
base_price=base_price,
|
||||
servers_price=servers_price,
|
||||
traffic_price=traffic_price,
|
||||
devices_price=devices_price,
|
||||
promo_group_discount=total_group_discount,
|
||||
promo_offer_discount=promo_offer_discount,
|
||||
final_total=max(0, final_total),
|
||||
period_days=period_days,
|
||||
is_tariff_mode=False,
|
||||
breakdown=breakdown,
|
||||
)
|
||||
|
||||
|
||||
# Module-level singleton — use this instead of PricingEngine()
|
||||
pricing_engine = PricingEngine()
|
||||
@@ -224,12 +224,15 @@ async def _process_single_subscription(
|
||||
autopay_period = 30
|
||||
|
||||
try:
|
||||
renewal_cost = await subscription_service.calculate_renewal_price(
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
subscription,
|
||||
autopay_period,
|
||||
db,
|
||||
user=user,
|
||||
)
|
||||
renewal_cost = pricing.final_total
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка расчёта стоимости для рекуррентного платежа',
|
||||
|
||||
@@ -1796,6 +1796,8 @@ class RemnaWaveService:
|
||||
|
||||
if panel_status == 'ACTIVE' and end_date_utc > current_time:
|
||||
new_status = SubscriptionStatus.ACTIVE.value
|
||||
elif panel_status == 'LIMITED':
|
||||
new_status = SubscriptionStatus.LIMITED.value
|
||||
elif panel_status == 'DISABLED':
|
||||
new_status = SubscriptionStatus.DISABLED.value
|
||||
elif end_date_utc <= current_time:
|
||||
@@ -2321,8 +2323,8 @@ class RemnaWaveService:
|
||||
async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> list[dict[str, Any]]:
|
||||
try:
|
||||
async with self.get_api_client() as api:
|
||||
start_str = start_date.isoformat() + 'Z'
|
||||
end_str = end_date.isoformat() + 'Z'
|
||||
start_str = start_date.isoformat().replace('+00:00', 'Z')
|
||||
end_str = end_date.isoformat().replace('+00:00', 'Z')
|
||||
|
||||
params = {'start': start_str, 'end': end_str}
|
||||
|
||||
@@ -2387,7 +2389,8 @@ class RemnaWaveService:
|
||||
|
||||
async def force_cleanup_user_data(self, db: AsyncSession, user: User) -> bool:
|
||||
"""
|
||||
ОПАСНАЯ ФУНКЦИЯ: Полностью сбрасывает все данные пользователя включая баланс!
|
||||
ОПАСНАЯ ФУНКЦИЯ: Полностью сбрасывает данные подписки пользователя.
|
||||
Баланс и has_had_paid_subscription СОХРАНЯЮТСЯ (оплаченные средства).
|
||||
Используйте только для полной очистки пользователя.
|
||||
"""
|
||||
try:
|
||||
@@ -2422,7 +2425,6 @@ class RemnaWaveService:
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.database.models import (
|
||||
PromoCodeUse,
|
||||
ReferralEarning,
|
||||
SubscriptionServer,
|
||||
SubscriptionStatus,
|
||||
@@ -2444,17 +2446,20 @@ class RemnaWaveService:
|
||||
await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user.id))
|
||||
logger.info('🗑️ Удалены реферальные доходы для', user_id_display=user_id_display)
|
||||
|
||||
await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id))
|
||||
logger.info('🗑️ Удалены использования промокодов для', user_id_display=user_id_display)
|
||||
# PromoCodeUse НЕ удаляем — история промокодов постоянна,
|
||||
# иначе пользователь может повторно активировать промокоды
|
||||
|
||||
except Exception as records_error:
|
||||
logger.error('❌ Ошибка удаления связанных записей', records_error=records_error)
|
||||
|
||||
try:
|
||||
user.balance_kopeks = 0
|
||||
if user.balance_kopeks > 0:
|
||||
logger.warning(
|
||||
'⚠️ force_cleanup: СОХРАНЯЕМ баланс пользователя (оплаченные средства)',
|
||||
user_id_display=user_id_display,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
)
|
||||
user.remnawave_uuid = None
|
||||
user.has_had_paid_subscription = False
|
||||
user.used_promocodes = 0
|
||||
user.updated_at = self._now_utc()
|
||||
|
||||
if user.subscription:
|
||||
@@ -2524,7 +2529,10 @@ class RemnaWaveService:
|
||||
stats['checked'] += 1
|
||||
user = subscription.user
|
||||
|
||||
if subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
if subscription.status in (
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
):
|
||||
continue
|
||||
|
||||
if user.telegram_id not in panel_telegram_ids:
|
||||
|
||||
@@ -530,7 +530,7 @@ class RemnaWaveWebhookService:
|
||||
) -> None:
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
if subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
if subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.LIMITED.value):
|
||||
await reactivate_subscription(db, subscription)
|
||||
logger.info(
|
||||
'Webhook: subscription re-enabled for user', subscription_id=subscription.id, user_id=user.id
|
||||
@@ -545,8 +545,11 @@ class RemnaWaveWebhookService:
|
||||
) -> None:
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value:
|
||||
await deactivate_subscription(db, subscription)
|
||||
if subscription.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value):
|
||||
subscription.status = SubscriptionStatus.LIMITED.value
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
logger.info(
|
||||
'Webhook: subscription limited (traffic) for user', subscription_id=subscription.id, user_id=user.id
|
||||
)
|
||||
@@ -561,8 +564,8 @@ class RemnaWaveWebhookService:
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
await update_subscription_usage(db, subscription, 0.0)
|
||||
# Re-enable if was disabled due to traffic limit
|
||||
if subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
# Re-enable if was disabled/limited due to traffic limit
|
||||
if subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.LIMITED.value):
|
||||
await reactivate_subscription(db, subscription)
|
||||
logger.info(
|
||||
'Webhook: traffic reset for subscription , user', subscription_id=subscription.id, user_id=user.id
|
||||
@@ -721,7 +724,7 @@ class RemnaWaveWebhookService:
|
||||
subscription.subscription_url = None
|
||||
subscription.subscription_crypto_link = None
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.connected_squads = None
|
||||
subscription.connected_squads = []
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
# Remove SubscriptionServer link rows
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.database.crud.user import get_user_by_id, subtract_user_balance
|
||||
from app.database.models import Subscription, SubscriptionStatus, TransactionType, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.pricing_engine import PricingEngine, pricing_engine
|
||||
from app.services.subscription_checkout_service import clear_subscription_checkout_draft
|
||||
from app.services.subscription_purchase_service import (
|
||||
MiniAppSubscriptionPurchaseService,
|
||||
@@ -142,55 +143,7 @@ def _safe_int(value: object | None, default: int = 0) -> int:
|
||||
|
||||
def _apply_promo_discount_for_tariff(price: int, discount_percent: int) -> int:
|
||||
"""Применяет скидку промогруппы к цене тарифа."""
|
||||
if discount_percent <= 0:
|
||||
return price
|
||||
discount = int(price * discount_percent / 100)
|
||||
return max(0, price - discount)
|
||||
|
||||
|
||||
async def _get_tariff_price_for_period(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
tariff_id: int,
|
||||
period_days: int,
|
||||
) -> tuple[int, int] | None:
|
||||
"""Получает базовую цену тарифа и процент скидки (без применения).
|
||||
|
||||
Returns:
|
||||
(base_price, discount_percent) или None если тариф/период недоступен.
|
||||
Скидка НЕ применяется — вызывающий код должен сначала добавить доп. устройства,
|
||||
затем применить скидку к полной сумме (как в cabinet).
|
||||
"""
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff or not tariff.is_active:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка: тариф недоступен для пользователя',
|
||||
tariff_id=tariff_id,
|
||||
format_user_id=_format_user_id(user),
|
||||
)
|
||||
return None
|
||||
|
||||
prices = tariff.period_prices or {}
|
||||
base_price = prices.get(str(period_days))
|
||||
if base_price is None:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка: период дней недоступен для тарифа', period_days=period_days, tariff_id=tariff_id
|
||||
)
|
||||
return None
|
||||
|
||||
# Возвращаем только promo_group скидку.
|
||||
# Promo_offer скидку вызывающий код должен применить отдельно (последовательно, как в cabinet).
|
||||
discount_percent = 0
|
||||
if hasattr(user, 'get_promo_discount'):
|
||||
discount_percent = user.get_promo_discount('period', period_days)
|
||||
else:
|
||||
promo_group = getattr(user, 'promo_group', None)
|
||||
if promo_group and hasattr(promo_group, 'get_discount_percent'):
|
||||
discount_percent = promo_group.get_discount_percent('period', period_days)
|
||||
|
||||
return (int(base_price), discount_percent)
|
||||
return PricingEngine.apply_discount(price, discount_percent)
|
||||
|
||||
|
||||
async def _prepare_auto_extend_context(
|
||||
@@ -229,56 +182,28 @@ async def _prepare_auto_extend_context(
|
||||
)
|
||||
return None
|
||||
|
||||
# Если в корзине есть tariff_id - пересчитываем цену по актуальному тарифу
|
||||
# Fresh pricing via unified PricingEngine (no stale cart prices)
|
||||
tariff_id = cart_data.get('tariff_id')
|
||||
if tariff_id:
|
||||
tariff_id = _safe_int(tariff_id)
|
||||
tariff_result = await _get_tariff_price_for_period(db, user, tariff_id, period_days)
|
||||
if tariff_result is None:
|
||||
# Тариф недоступен или период отсутствует - используем сохранённую цену как fallback
|
||||
price_kopeks = _safe_int(
|
||||
cart_data.get('total_price') or cart_data.get('price') or cart_data.get('final_price'),
|
||||
)
|
||||
logger.warning(
|
||||
'🔁 Автопокупка: не удалось пересчитать цену тарифа , используем сохранённую',
|
||||
tariff_id=tariff_id,
|
||||
price_kopeks=price_kopeks,
|
||||
)
|
||||
else:
|
||||
base_price, discount_percent = tariff_result
|
||||
price_kopeks = base_price
|
||||
|
||||
# Добавляем стоимость докупленных устройств ДО применения скидки (как в cabinet)
|
||||
if subscription.tariff_id == tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id as _get_tariff
|
||||
from app.services.pricing_engine import pricing_engine as _pricing_engine
|
||||
|
||||
_tariff = await _get_tariff(db, tariff_id)
|
||||
if _tariff:
|
||||
extra_devices = max(0, (subscription.device_limit or 0) - (_tariff.device_limit or 0))
|
||||
if extra_devices > 0:
|
||||
from app.utils.pricing_utils import calculate_months_from_days
|
||||
|
||||
device_price_per_month = (
|
||||
_tariff.device_price_kopeks
|
||||
if _tariff.device_price_kopeks is not None
|
||||
else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
months = calculate_months_from_days(period_days)
|
||||
price_kopeks += extra_devices * device_price_per_month * months
|
||||
|
||||
# Применяем promo_group скидку к полной сумме (база + доп. устройства)
|
||||
price_kopeks = _apply_promo_discount_for_tariff(price_kopeks, discount_percent)
|
||||
|
||||
# Применяем promo_offer скидку отдельно (последовательно, как в cabinet)
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
promo_offer_percent = get_user_active_promo_discount_percent(user)
|
||||
if promo_offer_percent > 0:
|
||||
price_kopeks = _apply_promo_discount_for_tariff(price_kopeks, promo_offer_percent)
|
||||
else:
|
||||
price_kopeks = _safe_int(
|
||||
cart_data.get('total_price') or cart_data.get('price') or cart_data.get('final_price'),
|
||||
try:
|
||||
pricing = await _pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
subscription,
|
||||
period_days,
|
||||
user=user,
|
||||
)
|
||||
price_kopeks = pricing.final_total
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Автопокупка: ошибка PricingEngine, пропускаем автопродление',
|
||||
format_user_id=_format_user_id(user),
|
||||
error=str(e),
|
||||
)
|
||||
return None
|
||||
|
||||
if price_kopeks <= 0:
|
||||
logger.warning(
|
||||
@@ -398,6 +323,15 @@ async def _auto_extend_subscription(
|
||||
)
|
||||
return False
|
||||
|
||||
# Save promo offer state before charge so we can restore on failure
|
||||
saved_promo_percent = (
|
||||
int(getattr(user, 'promo_offer_discount_percent', 0) or 0) if prepared.consume_promo_offer else 0
|
||||
)
|
||||
saved_promo_source = getattr(user, 'promo_offer_discount_source', None) if prepared.consume_promo_offer else None
|
||||
saved_promo_expires = (
|
||||
getattr(user, 'promo_offer_discount_expires_at', None) if prepared.consume_promo_offer else None
|
||||
)
|
||||
|
||||
try:
|
||||
deducted = await subtract_user_balance(
|
||||
db,
|
||||
@@ -462,8 +396,44 @@ async def _auto_extend_subscription(
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
# НОВОЕ: Откатываем изменения при ошибке
|
||||
await db.rollback()
|
||||
# Compensating refund: balance was already committed by subtract_user_balance
|
||||
try:
|
||||
from app.database.crud.user import add_user_balance
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
prepared.price_kopeks,
|
||||
'Возврат: ошибка автопродления подписки',
|
||||
create_transaction=True,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
|
||||
# Restore consumed promo offer fields
|
||||
if prepared.consume_promo_offer and saved_promo_percent > 0:
|
||||
user.promo_offer_discount_percent = saved_promo_percent
|
||||
user.promo_offer_discount_source = saved_promo_source
|
||||
user.promo_offer_discount_expires_at = saved_promo_expires
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'💰 Автопокупка: восстановлен промо-оффер после ошибки продления',
|
||||
format_user_id=_format_user_id(user),
|
||||
restored_percent=saved_promo_percent,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'💰 Автопокупка: возврат средств после ошибки продления',
|
||||
format_user_id=_format_user_id(user),
|
||||
refund_kopeks=prepared.price_kopeks,
|
||||
)
|
||||
except Exception as refund_error:
|
||||
logger.critical(
|
||||
'CRITICAL: Автопокупка: не удалось вернуть средства после ошибки продления',
|
||||
format_user_id=_format_user_id(user),
|
||||
price_kopeks=prepared.price_kopeks,
|
||||
refund_error=refund_error,
|
||||
)
|
||||
return False
|
||||
|
||||
transaction = None
|
||||
@@ -673,13 +643,10 @@ async def _auto_purchase_tariff(
|
||||
if existing_subscription and existing_subscription.tariff_id == tariff_id:
|
||||
extra_devices = max(0, (existing_subscription.device_limit or 0) - (tariff.device_limit or 0))
|
||||
if extra_devices > 0:
|
||||
from app.utils.pricing_utils import calculate_months_from_days
|
||||
|
||||
device_price_per_month = (
|
||||
device_price_per_unit = (
|
||||
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
months = calculate_months_from_days(period_days)
|
||||
extra_devices_cost = extra_devices * device_price_per_month * months
|
||||
extra_devices_cost = extra_devices * device_price_per_unit
|
||||
final_price += extra_devices_cost
|
||||
|
||||
# Пересчитываем скидку из актуальных данных пользователя (не из stale корзины)
|
||||
@@ -706,6 +673,12 @@ async def _auto_purchase_tariff(
|
||||
)
|
||||
return False
|
||||
|
||||
# Save promo offer state before deduction (for restore on failure)
|
||||
consume_promo = promo_offer_percent > 0
|
||||
saved_promo_percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0) if consume_promo else 0
|
||||
saved_promo_source = getattr(user, 'promo_offer_discount_source', None) if consume_promo else None
|
||||
saved_promo_expires = getattr(user, 'promo_offer_discount_expires_at', None) if consume_promo else None
|
||||
|
||||
# Списываем баланс
|
||||
try:
|
||||
description = f'Покупка тарифа {tariff.name} на {period_days} дней'
|
||||
@@ -714,7 +687,7 @@ async def _auto_purchase_tariff(
|
||||
user,
|
||||
final_price,
|
||||
description,
|
||||
consume_promo_offer=promo_offer_percent > 0,
|
||||
consume_promo_offer=consume_promo,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
@@ -779,6 +752,36 @@ async def _auto_purchase_tariff(
|
||||
exc_info=True,
|
||||
)
|
||||
await db.rollback()
|
||||
# Compensating refund: balance was already committed by subtract_user_balance
|
||||
try:
|
||||
from app.database.crud.user import add_user_balance
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
final_price,
|
||||
'Возврат: ошибка автопокупки тарифа',
|
||||
create_transaction=True,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
# Restore promo offer if consumed
|
||||
if consume_promo and saved_promo_percent > 0:
|
||||
user.promo_offer_discount_percent = saved_promo_percent
|
||||
user.promo_offer_discount_source = saved_promo_source
|
||||
user.promo_offer_discount_expires_at = saved_promo_expires
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'💰 Автопокупка тарифа: возврат средств после ошибки создания подписки',
|
||||
format_user_id=_format_user_id(user),
|
||||
refund_kopeks=final_price,
|
||||
)
|
||||
except Exception as refund_error:
|
||||
logger.critical(
|
||||
'CRITICAL: Автопокупка тарифа: не удалось вернуть средства после ошибки создания подписки',
|
||||
format_user_id=_format_user_id(user),
|
||||
price_kopeks=final_price,
|
||||
refund_error=refund_error,
|
||||
)
|
||||
return False
|
||||
|
||||
# Создаём транзакцию
|
||||
@@ -1023,9 +1026,20 @@ async def _auto_purchase_daily_tariff(
|
||||
# Обновляем существующую подписку на суточный тариф
|
||||
# Суточность определяется через tariff.is_daily, поэтому достаточно установить tariff_id
|
||||
was_trial_conversion = existing_subscription.is_trial # Сохраняем до изменения
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
from app.database.crud.tariff import get_tariff_by_id as _get_old_tariff
|
||||
|
||||
old_tariff = (
|
||||
await _get_old_tariff(db, existing_subscription.tariff_id) if existing_subscription.tariff_id else None
|
||||
)
|
||||
existing_subscription.tariff_id = tariff.id
|
||||
existing_subscription.traffic_limit_gb = tariff.traffic_limit_gb
|
||||
existing_subscription.device_limit = tariff.device_limit
|
||||
existing_subscription.device_limit = calc_device_limit_on_tariff_switch(
|
||||
current_device_limit=existing_subscription.device_limit,
|
||||
old_tariff_device_limit=old_tariff.device_limit if old_tariff else None,
|
||||
new_tariff_device_limit=tariff.device_limit,
|
||||
max_device_limit=getattr(tariff, 'max_device_limit', None),
|
||||
)
|
||||
existing_subscription.connected_squads = squads
|
||||
existing_subscription.status = 'active'
|
||||
existing_subscription.is_trial = False
|
||||
@@ -1060,6 +1074,30 @@ async def _auto_purchase_daily_tariff(
|
||||
exc_info=True,
|
||||
)
|
||||
await db.rollback()
|
||||
# Compensating refund: balance was already committed by subtract_user_balance
|
||||
try:
|
||||
from app.database.crud.user import add_user_balance
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
daily_price,
|
||||
'Возврат: ошибка автопокупки суточного тарифа',
|
||||
create_transaction=True,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
logger.info(
|
||||
'💰 Автопокупка суточного тарифа: возврат средств после ошибки создания подписки',
|
||||
format_user_id=_format_user_id(user),
|
||||
refund_kopeks=daily_price,
|
||||
)
|
||||
except Exception as refund_error:
|
||||
logger.critical(
|
||||
'CRITICAL: Автопокупка суточного тарифа: не удалось вернуть средства',
|
||||
format_user_id=_format_user_id(user),
|
||||
price_kopeks=daily_price,
|
||||
refund_error=refund_error,
|
||||
)
|
||||
return False
|
||||
|
||||
# Создаём транзакцию
|
||||
@@ -1244,7 +1282,7 @@ async def _auto_add_devices(
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
return False
|
||||
|
||||
if subscription.status not in ('active', 'trial', 'disabled', 'ACTIVE', 'TRIAL', 'DISABLED'):
|
||||
if subscription.status not in ('active', 'trial', 'disabled', 'limited', 'ACTIVE', 'TRIAL', 'DISABLED', 'LIMITED'):
|
||||
logger.warning(
|
||||
'🔁 Автопокупка устройств: подписка пользователя не активна (status=)',
|
||||
format_user_id=_format_user_id(user),
|
||||
@@ -1361,7 +1399,7 @@ async def _auto_add_devices(
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -1370,6 +1408,9 @@ async def _auto_add_devices(
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка устройств: не удалось обновить Remnawave для пользователя',
|
||||
@@ -1510,7 +1551,7 @@ async def _auto_add_traffic(
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
return False
|
||||
|
||||
if subscription.status not in ('active', 'trial', 'disabled', 'ACTIVE', 'TRIAL', 'DISABLED'):
|
||||
if subscription.status not in ('active', 'trial', 'disabled', 'limited', 'ACTIVE', 'TRIAL', 'DISABLED', 'LIMITED'):
|
||||
logger.warning(
|
||||
'🔁 Автопокупка трафика: подписка пользователя не активна (status=)',
|
||||
format_user_id=_format_user_id(user),
|
||||
@@ -1571,9 +1612,33 @@ async def _auto_add_traffic(
|
||||
exc_info=True,
|
||||
)
|
||||
await db.rollback()
|
||||
# Compensating refund: balance was already committed by subtract_user_balance
|
||||
try:
|
||||
from app.database.crud.user import add_user_balance
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
price_kopeks,
|
||||
'Возврат: ошибка автопокупки трафика',
|
||||
create_transaction=True,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
logger.info(
|
||||
'💰 Автопокупка трафика: возврат средств после ошибки добавления трафика',
|
||||
format_user_id=_format_user_id(user),
|
||||
refund_kopeks=price_kopeks,
|
||||
)
|
||||
except Exception as refund_error:
|
||||
logger.critical(
|
||||
'CRITICAL: Автопокупка трафика: не удалось вернуть средства',
|
||||
format_user_id=_format_user_id(user),
|
||||
price_kopeks=price_kopeks,
|
||||
refund_error=refund_error,
|
||||
)
|
||||
return False
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -1582,6 +1647,9 @@ async def _auto_add_traffic(
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка трафика: не удалось обновить Remnawave для пользователя',
|
||||
@@ -1733,15 +1801,16 @@ async def try_auto_extend_expired_after_topup(
|
||||
else:
|
||||
period_days = 30
|
||||
|
||||
# Calculate renewal price
|
||||
# Calculate renewal price via PricingEngine
|
||||
subscription_service = SubscriptionService()
|
||||
try:
|
||||
renewal_cost = await subscription_service.calculate_renewal_price(
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
subscription,
|
||||
period_days,
|
||||
db,
|
||||
user=user,
|
||||
)
|
||||
renewal_cost = pricing.final_total
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'❌ Автопродление expired: ошибка расчёта стоимости',
|
||||
@@ -1751,6 +1820,15 @@ async def try_auto_extend_expired_after_topup(
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
'Расчёт цены автопродления (PricingEngine)',
|
||||
user_id=getattr(user, 'id', None),
|
||||
period_days=period_days,
|
||||
final_total=pricing.final_total,
|
||||
is_tariff_mode=pricing.is_tariff_mode,
|
||||
breakdown=pricing.breakdown,
|
||||
)
|
||||
|
||||
if renewal_cost <= 0:
|
||||
logger.warning(
|
||||
'❌ Автопродление expired: некорректная стоимость',
|
||||
@@ -1797,6 +1875,11 @@ async def try_auto_extend_expired_after_topup(
|
||||
|
||||
consume_promo_offer = get_user_active_promo_discount_percent(user) > 0
|
||||
|
||||
# Save promo offer state before deduction (for restore on failure)
|
||||
saved_promo_percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0) if consume_promo_offer else 0
|
||||
saved_promo_source = getattr(user, 'promo_offer_discount_source', None) if consume_promo_offer else None
|
||||
saved_promo_expires = getattr(user, 'promo_offer_discount_expires_at', None) if consume_promo_offer else None
|
||||
|
||||
# Deduct balance
|
||||
description = f'Автопродление истёкшей подписки на {period_days} дней'
|
||||
try:
|
||||
@@ -1849,6 +1932,36 @@ async def try_auto_extend_expired_after_topup(
|
||||
exc_info=True,
|
||||
)
|
||||
await db.rollback()
|
||||
# Compensating refund: balance was already committed by subtract_user_balance
|
||||
try:
|
||||
from app.database.crud.user import add_user_balance
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
renewal_cost,
|
||||
'Возврат: ошибка автопродления истёкшей подписки',
|
||||
create_transaction=True,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
# Restore promo offer if consumed
|
||||
if consume_promo_offer and saved_promo_percent > 0:
|
||||
user.promo_offer_discount_percent = saved_promo_percent
|
||||
user.promo_offer_discount_source = saved_promo_source
|
||||
user.promo_offer_discount_expires_at = saved_promo_expires
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'💰 Автопродление expired: возврат средств после ошибки продления',
|
||||
format_user_id=_format_user_id(user),
|
||||
refund_kopeks=renewal_cost,
|
||||
)
|
||||
except Exception as refund_error:
|
||||
logger.critical(
|
||||
'CRITICAL: Автопродление expired: не удалось вернуть средства',
|
||||
format_user_id=_format_user_id(user),
|
||||
price_kopeks=renewal_cost,
|
||||
refund_error=refund_error,
|
||||
)
|
||||
return False
|
||||
|
||||
# Create transaction record
|
||||
@@ -2010,8 +2123,12 @@ async def try_resume_disabled_daily_after_topup(
|
||||
if subscription is None:
|
||||
return False
|
||||
|
||||
# Only handle DISABLED (or EXPIRED) daily tariff subscriptions
|
||||
if subscription.status not in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
|
||||
# Only handle DISABLED/LIMITED (or EXPIRED) daily tariff subscriptions
|
||||
if subscription.status not in (
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
):
|
||||
return False
|
||||
if not getattr(subscription, 'is_daily_tariff', False):
|
||||
return False
|
||||
@@ -2103,6 +2220,30 @@ async def try_resume_disabled_daily_after_topup(
|
||||
exc_info=True,
|
||||
)
|
||||
await db.rollback()
|
||||
# Compensating refund: balance was already committed by subtract_user_balance
|
||||
try:
|
||||
from app.database.crud.user import add_user_balance
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
daily_price,
|
||||
'Возврат: ошибка авто-возобновления суточной подписки',
|
||||
create_transaction=True,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
logger.info(
|
||||
'💰 Авто-возобновление daily: возврат средств после ошибки активации',
|
||||
format_user_id=_format_user_id(user),
|
||||
refund_kopeks=daily_price,
|
||||
)
|
||||
except Exception as refund_error:
|
||||
logger.critical(
|
||||
'CRITICAL: Авто-возобновление daily: не удалось вернуть средства',
|
||||
format_user_id=_format_user_id(user),
|
||||
price_kopeks=daily_price,
|
||||
refund_error=refund_error,
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
@@ -2277,6 +2418,24 @@ async def auto_purchase_saved_cart_after_topup(
|
||||
|
||||
logger.info('🔁 Автопокупка: обнаружена сохранённая корзина у пользователя', format_user_id=_format_user_id(user))
|
||||
|
||||
# Защита от автопокупки на DISABLED подписке — пользователь отключён в панели,
|
||||
# сохранённая корзина устарела. Списание баланса необратимо, а Remnawave-обновление
|
||||
# провалится → баланс потерян навсегда.
|
||||
# Суточные тарифы тоже блокируем: try_resume_disabled_daily_after_topup уже отработал
|
||||
# выше по цепочке (common.py), и если он не возобновил — причина сохраняется.
|
||||
from app.database.crud.subscription import get_subscription_by_user_id as _get_sub
|
||||
|
||||
_existing_sub = await _get_sub(db, user.id)
|
||||
if _existing_sub and _existing_sub.status == SubscriptionStatus.DISABLED.value:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка: пропускаем — подписка DISABLED, корзина устарела',
|
||||
format_user_id=_format_user_id(user),
|
||||
subscription_status=_existing_sub.status,
|
||||
)
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
await clear_subscription_checkout_draft(user.id)
|
||||
return False
|
||||
|
||||
cart_mode = cart_data.get('cart_mode') or cart_data.get('mode')
|
||||
|
||||
# Защита от race condition: если подписка была куплена/продлена в последние 60 секунд,
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.database.models import ServerSquad, Subscription, SubscriptionStatus, T
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
calculate_months_from_days,
|
||||
format_period_description,
|
||||
validate_pricing_calculation,
|
||||
@@ -266,21 +267,8 @@ class PurchaseBalanceError(Exception):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def _apply_percentage_discount(amount: int, percent: int) -> tuple[int, int]:
|
||||
if amount <= 0 or percent <= 0:
|
||||
return amount, 0
|
||||
clamped = max(0, min(100, percent))
|
||||
discount_value = amount * clamped // 100
|
||||
discounted = amount - discount_value
|
||||
if discount_value >= 100 and discounted % 100:
|
||||
discounted += 100 - (discounted % 100)
|
||||
discounted = min(discounted, amount)
|
||||
discount_value = amount - discounted
|
||||
return discounted, discount_value
|
||||
|
||||
|
||||
def _apply_discount_to_monthly_component(amount_per_month: int, percent: int, months: int) -> dict[str, int]:
|
||||
discounted_per_month, discount_per_month = _apply_percentage_discount(amount_per_month, percent)
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(amount_per_month, percent)
|
||||
return {
|
||||
'original_per_month': amount_per_month,
|
||||
'discounted_per_month': discounted_per_month,
|
||||
@@ -299,7 +287,7 @@ def _apply_promo_offer_discount(user: User | None, amount: int) -> tuple[int, in
|
||||
percent = _get_promo_offer_discount_percent(user)
|
||||
if amount <= 0 or percent <= 0:
|
||||
return amount, 0, 0
|
||||
discounted, discount_value = _apply_percentage_discount(amount, percent)
|
||||
discounted, discount_value = apply_percentage_discount(amount, percent)
|
||||
return discounted, discount_value, percent
|
||||
|
||||
|
||||
@@ -309,7 +297,7 @@ def _build_server_option(
|
||||
texts,
|
||||
) -> PurchaseServerOption:
|
||||
base_per_month = int(getattr(server, 'price_kopeks', 0) or 0)
|
||||
discounted_per_month, _ = _apply_percentage_discount(base_per_month, discount_percent)
|
||||
discounted_per_month, _ = apply_percentage_discount(base_per_month, discount_percent)
|
||||
return PurchaseServerOption(
|
||||
uuid=server.squad_uuid,
|
||||
name=getattr(server, 'display_name', server.squad_uuid) or server.squad_uuid,
|
||||
@@ -393,7 +381,7 @@ class MiniAppSubscriptionPurchaseService:
|
||||
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
period_discount_percent = user.get_promo_discount('period', period_days)
|
||||
base_price, base_discount_total = _apply_percentage_discount(base_price_original, period_discount_percent)
|
||||
base_price, base_discount_total = apply_percentage_discount(base_price_original, period_discount_percent)
|
||||
base_price_label = texts.format_price(base_price)
|
||||
base_price_original_label = (
|
||||
texts.format_price(base_price_original)
|
||||
@@ -526,7 +514,7 @@ class MiniAppSubscriptionPurchaseService:
|
||||
for package in packages:
|
||||
value = int(package.get('gb') or 0)
|
||||
price_per_month = int(package.get('price') or 0)
|
||||
discounted_per_month, discount_value = _apply_percentage_discount(price_per_month, discount_percent)
|
||||
discounted_per_month, discount_value = apply_percentage_discount(price_per_month, discount_percent)
|
||||
label = texts.format_traffic(value or 0)
|
||||
options.append(
|
||||
PurchaseTrafficOption(
|
||||
@@ -600,7 +588,7 @@ class MiniAppSubscriptionPurchaseService:
|
||||
) -> PurchaseDevicesConfig:
|
||||
discount_percent = user.get_promo_discount('devices', period_days)
|
||||
unit_price = settings.PRICE_PER_DEVICE
|
||||
discounted_unit_price, unit_discount_value = _apply_percentage_discount(unit_price, discount_percent)
|
||||
discounted_unit_price, unit_discount_value = apply_percentage_discount(unit_price, discount_percent)
|
||||
price_label = texts.format_price(discounted_unit_price)
|
||||
original_label = (
|
||||
texts.format_price(unit_price) if unit_discount_value and unit_price != discounted_unit_price else None
|
||||
|
||||
@@ -4,7 +4,7 @@ import base64
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -13,24 +13,17 @@ from aiogram import Bot
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids, get_server_squads_by_uuids
|
||||
from app.database.crud.subscription import (
|
||||
add_subscription_servers,
|
||||
calculate_subscription_total_cost,
|
||||
extend_subscription,
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import PaymentMethod, Subscription, Transaction, TransactionType, User
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.pricing_engine import RenewalPricing
|
||||
from app.services.remnawave_service import RemnaWaveConfigurationError
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
calculate_months_from_days,
|
||||
format_period_description,
|
||||
validate_pricing_calculation,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -77,19 +70,67 @@ class SubscriptionRenewalPricing:
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: dict[str, Any]) -> SubscriptionRenewalPricing:
|
||||
"""Deserialize from dict. Supports both legacy SubscriptionRenewalPricing
|
||||
and new RenewalPricing (from PricingEngine) schemas."""
|
||||
breakdown = payload.get('breakdown') or {}
|
||||
period_days = int(payload.get('period_days', 0) or 0)
|
||||
final_total = int(payload.get('final_total', 0) or 0)
|
||||
|
||||
# RenewalPricing uses 'promo_offer_discount', legacy uses 'promo_discount_value'
|
||||
promo_discount_value = int(
|
||||
payload.get('promo_discount_value', 0) or payload.get('promo_offer_discount', 0) or 0
|
||||
)
|
||||
|
||||
# months: legacy has it directly, RenewalPricing needs derivation
|
||||
months = int(payload.get('months', 0) or 0)
|
||||
if not months and period_days > 0:
|
||||
months = max(1, round(period_days / 30))
|
||||
|
||||
# base_original_total: legacy has it, RenewalPricing needs reconstruction
|
||||
base_original_total = int(payload.get('base_original_total', 0) or 0)
|
||||
if not base_original_total and final_total > 0:
|
||||
promo_group_discount = int(payload.get('promo_group_discount', 0) or 0)
|
||||
base_original_total = final_total + promo_group_discount + promo_discount_value
|
||||
|
||||
# discounted_total: legacy has it, RenewalPricing = final + offer discount
|
||||
discounted_total = int(payload.get('discounted_total', 0) or 0)
|
||||
if not discounted_total:
|
||||
discounted_total = final_total + promo_discount_value
|
||||
|
||||
# per_month
|
||||
per_month = int(payload.get('per_month', 0) or 0)
|
||||
if not per_month and months > 0:
|
||||
per_month = final_total // months
|
||||
|
||||
# server_ids: legacy at top level, RenewalPricing in breakdown
|
||||
server_ids = list(payload.get('server_ids') or breakdown.get('server_ids') or [])
|
||||
|
||||
# details: legacy uses 'details', RenewalPricing uses 'breakdown'
|
||||
details = dict(payload.get('details') or breakdown or {})
|
||||
|
||||
# promo_discount_percent: from payload or breakdown
|
||||
promo_discount_percent = int(
|
||||
payload.get('promo_discount_percent', 0) or breakdown.get('offer_discount_pct', 0) or 0
|
||||
)
|
||||
|
||||
# overall_discount_percent: derive if not present
|
||||
overall_discount_percent = int(payload.get('overall_discount_percent', 0) or 0)
|
||||
if not overall_discount_percent and base_original_total > 0 and base_original_total > final_total:
|
||||
overall_discount_percent = int(round((base_original_total - final_total) * 100 / base_original_total))
|
||||
|
||||
return cls(
|
||||
period_days=int(payload.get('period_days', 0) or 0),
|
||||
period_id=str(payload.get('period_id') or build_renewal_period_id(int(payload.get('period_days', 0) or 0))),
|
||||
months=int(payload.get('months', 0) or 0),
|
||||
base_original_total=int(payload.get('base_original_total', 0) or 0),
|
||||
discounted_total=int(payload.get('discounted_total', 0) or 0),
|
||||
final_total=int(payload.get('final_total', 0) or 0),
|
||||
promo_discount_value=int(payload.get('promo_discount_value', 0) or 0),
|
||||
promo_discount_percent=int(payload.get('promo_discount_percent', 0) or 0),
|
||||
overall_discount_percent=int(payload.get('overall_discount_percent', 0) or 0),
|
||||
per_month=int(payload.get('per_month', 0) or 0),
|
||||
server_ids=list(payload.get('server_ids', []) or []),
|
||||
details=dict(payload.get('details', {}) or {}),
|
||||
period_days=period_days,
|
||||
period_id=str(payload.get('period_id') or build_renewal_period_id(period_days)),
|
||||
months=months,
|
||||
base_original_total=base_original_total,
|
||||
discounted_total=discounted_total,
|
||||
final_total=final_total,
|
||||
promo_discount_value=promo_discount_value,
|
||||
promo_discount_percent=promo_discount_percent,
|
||||
overall_discount_percent=overall_discount_percent,
|
||||
per_month=per_month,
|
||||
server_ids=server_ids,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
@@ -309,145 +350,12 @@ async def with_admin_notification_service(
|
||||
class SubscriptionRenewalService:
|
||||
"""Shared helpers for subscription renewal pricing and processing."""
|
||||
|
||||
async def calculate_pricing(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
) -> SubscriptionRenewalPricing:
|
||||
connected_uuids = [str(uuid) for uuid in list(subscription.connected_squads or [])]
|
||||
server_ids: list[int] = []
|
||||
if connected_uuids:
|
||||
server_ids = await get_server_ids_by_uuids(db, connected_uuids)
|
||||
|
||||
# Валидация: проверяем доступность серверов для промогруппы пользователя
|
||||
await self._validate_servers_for_user_promo_group(db, user, connected_uuids)
|
||||
|
||||
# В режиме fixed_with_topup при продлении используем фиксированный лимит
|
||||
purchased_traffic = subscription.purchased_traffic_gb or 0
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_limit = settings.get_fixed_traffic_limit()
|
||||
# Separate base traffic from purchased to avoid wrong tier lookup
|
||||
# e.g. 25GB base + 100GB purchased = 125GB total → would round up to 250GB tier
|
||||
elif purchased_traffic > 0:
|
||||
base_traffic = (subscription.traffic_limit_gb or 0) - purchased_traffic
|
||||
if base_traffic <= 0:
|
||||
logger.warning(
|
||||
'Purchased traffic >= total limit, pricing purchased portion only',
|
||||
subscription_id=subscription.id,
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
purchased_traffic_gb=purchased_traffic,
|
||||
)
|
||||
# All traffic is purchased; pass it as sole traffic_limit
|
||||
# and clear purchased_traffic to avoid double-counting below
|
||||
traffic_limit = purchased_traffic
|
||||
purchased_traffic = 0
|
||||
else:
|
||||
traffic_limit = base_traffic
|
||||
else:
|
||||
traffic_limit = subscription.traffic_limit_gb
|
||||
if traffic_limit is None:
|
||||
traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB
|
||||
|
||||
devices_limit = subscription.device_limit
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
total_cost, details = await calculate_subscription_total_cost(
|
||||
db,
|
||||
period_days,
|
||||
int(traffic_limit or 0),
|
||||
server_ids,
|
||||
int(devices_limit or 0),
|
||||
user=user,
|
||||
)
|
||||
|
||||
months = details.get('months_in_period') or calculate_months_from_days(period_days)
|
||||
|
||||
# Add purchased traffic cost separately (uses its own tier price, same discount %)
|
||||
if purchased_traffic > 0 and not settings.is_traffic_fixed():
|
||||
purchased_price_per_month = settings.get_traffic_price(purchased_traffic)
|
||||
traffic_discount_pct = details.get('traffic_discount_percent', 0)
|
||||
purchased_disc_per_month = purchased_price_per_month * traffic_discount_pct // 100
|
||||
discounted_purchased_per_month = purchased_price_per_month - purchased_disc_per_month
|
||||
purchased_total = discounted_purchased_per_month * months
|
||||
purchased_disc_total = purchased_disc_per_month * months
|
||||
|
||||
total_cost += purchased_total
|
||||
details['traffic_price_per_month'] = details.get('traffic_price_per_month', 0) + purchased_price_per_month
|
||||
details['total_traffic_price'] = details.get('total_traffic_price', 0) + purchased_total
|
||||
details['traffic_discount_total'] = details.get('traffic_discount_total', 0) + purchased_disc_total
|
||||
|
||||
base_original_total = (
|
||||
details.get('base_price_original', 0)
|
||||
+ details.get('traffic_price_per_month', 0) * months
|
||||
+ details.get('servers_price_per_month', 0) * months
|
||||
+ details.get('devices_price_per_month', 0) * months
|
||||
)
|
||||
|
||||
discounted_total = total_cost
|
||||
|
||||
monthly_additions = 0
|
||||
if months > 0:
|
||||
monthly_additions = (
|
||||
details.get('total_servers_price', 0) // months
|
||||
+ details.get('total_devices_price', 0) // months
|
||||
+ details.get('total_traffic_price', 0) // months
|
||||
)
|
||||
|
||||
if not validate_pricing_calculation(
|
||||
details.get('base_price', 0),
|
||||
monthly_additions,
|
||||
months,
|
||||
discounted_total,
|
||||
):
|
||||
logger.warning(
|
||||
'Renewal pricing validation failed for subscription (period)',
|
||||
subscription_id=subscription.id,
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
promo_percent = get_user_active_promo_discount_percent(user)
|
||||
|
||||
final_total = discounted_total
|
||||
promo_discount_value = 0
|
||||
if promo_percent > 0 and discounted_total > 0:
|
||||
final_total, promo_discount_value = apply_percentage_discount(
|
||||
discounted_total,
|
||||
promo_percent,
|
||||
)
|
||||
|
||||
overall_discount_value = max(0, base_original_total - final_total)
|
||||
overall_discount_percent = 0
|
||||
if base_original_total > 0 and overall_discount_value > 0:
|
||||
overall_discount_percent = int(round(overall_discount_value * 100 / base_original_total))
|
||||
|
||||
per_month = final_total // months if months else final_total
|
||||
|
||||
return SubscriptionRenewalPricing(
|
||||
period_days=period_days,
|
||||
period_id=build_renewal_period_id(period_days),
|
||||
months=months,
|
||||
base_original_total=base_original_total,
|
||||
discounted_total=discounted_total,
|
||||
final_total=final_total,
|
||||
promo_discount_value=promo_discount_value,
|
||||
promo_discount_percent=promo_percent if promo_discount_value else 0,
|
||||
overall_discount_percent=overall_discount_percent,
|
||||
per_month=per_month,
|
||||
server_ids=list(server_ids),
|
||||
details=details,
|
||||
)
|
||||
|
||||
async def finalize(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
pricing: SubscriptionRenewalPricing,
|
||||
pricing: SubscriptionRenewalPricing | RenewalPricing,
|
||||
*,
|
||||
charge_balance_amount: int | None = None,
|
||||
description: str | None = None,
|
||||
@@ -462,10 +370,19 @@ class SubscriptionRenewalService:
|
||||
charge_from_balance = final_total
|
||||
charge_from_balance = max(0, min(charge_from_balance, final_total))
|
||||
|
||||
consume_promo_offer = bool(pricing.promo_discount_value)
|
||||
# Support both SubscriptionRenewalPricing and RenewalPricing
|
||||
if isinstance(pricing, SubscriptionRenewalPricing):
|
||||
consume_promo_offer = bool(pricing.promo_discount_value)
|
||||
else:
|
||||
consume_promo_offer = bool(pricing.promo_offer_discount)
|
||||
|
||||
description_text = description or f'Продление подписки на {period_days} дней'
|
||||
|
||||
# Save promo offer state before charge so we can restore on failure
|
||||
saved_promo_percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0) if consume_promo_offer else 0
|
||||
saved_promo_source = getattr(user, 'promo_offer_discount_source', None) if consume_promo_offer else None
|
||||
saved_promo_expires = getattr(user, 'promo_offer_discount_expires_at', None) if consume_promo_offer else None
|
||||
|
||||
if charge_from_balance > 0 or consume_promo_offer:
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
@@ -479,25 +396,64 @@ class SubscriptionRenewalService:
|
||||
raise SubscriptionRenewalChargeError('Failed to charge balance')
|
||||
await db.refresh(user)
|
||||
|
||||
subscription_before = subscription
|
||||
# Lock subscription row to prevent double-extension race
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.database.models import Subscription as SubscriptionModel
|
||||
|
||||
locked_result = await db.execute(
|
||||
sa_select(SubscriptionModel)
|
||||
.where(SubscriptionModel.id == subscription.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
subscription_before = locked_result.scalar_one()
|
||||
old_end_date = subscription_before.end_date
|
||||
|
||||
# Determine expired state BEFORE extend_subscription mutates the object
|
||||
now = datetime.now(UTC)
|
||||
was_expired = subscription_before.status in ('expired', 'disabled', 'limited') or (
|
||||
subscription_before.end_date is not None and subscription_before.end_date <= now
|
||||
)
|
||||
|
||||
try:
|
||||
subscription_after = await extend_subscription(db, subscription_before, period_days)
|
||||
except Exception:
|
||||
# Session may be in a failed state after a broken commit — rollback first
|
||||
await db.rollback()
|
||||
|
||||
# Compensate: refund the charged balance since extension failed
|
||||
if charge_from_balance > 0:
|
||||
if charge_from_balance > 0 or (consume_promo_offer and saved_promo_percent > 0):
|
||||
try:
|
||||
from app.database.crud.user import add_user_balance
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
charge_from_balance,
|
||||
'Возврат: ошибка продления подписки',
|
||||
create_transaction=True,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
if charge_from_balance > 0:
|
||||
refunded = await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
charge_from_balance,
|
||||
'Возврат: ошибка продления подписки',
|
||||
create_transaction=True,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
if not refunded:
|
||||
logger.critical(
|
||||
'CRITICAL: add_user_balance returned False during refund',
|
||||
charge_from_balance=charge_from_balance,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
# Restore consumed promo offer fields
|
||||
if consume_promo_offer and saved_promo_percent > 0:
|
||||
user.promo_offer_discount_percent = saved_promo_percent
|
||||
user.promo_offer_discount_source = saved_promo_source
|
||||
user.promo_offer_discount_expires_at = saved_promo_expires
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'Restored promo offer after failed extension',
|
||||
user_id=user.id,
|
||||
restored_percent=saved_promo_percent,
|
||||
)
|
||||
except Exception as refund_error:
|
||||
logger.critical(
|
||||
'CRITICAL: Failed to refund kopeks to user after extension failure',
|
||||
@@ -507,8 +463,14 @@ class SubscriptionRenewalService:
|
||||
)
|
||||
raise
|
||||
|
||||
server_ids = pricing.server_ids or []
|
||||
server_prices_for_period = pricing.details.get('servers_individual_prices', [])
|
||||
# Support both SubscriptionRenewalPricing (server_ids, details) and RenewalPricing (breakdown)
|
||||
if isinstance(pricing, SubscriptionRenewalPricing):
|
||||
server_ids = pricing.server_ids or []
|
||||
server_prices_for_period = (pricing.details or {}).get('servers_individual_prices', [])
|
||||
else:
|
||||
breakdown = pricing.breakdown or {}
|
||||
server_ids = breakdown.get('server_ids', [])
|
||||
server_prices_for_period = breakdown.get('servers_individual_prices', [])
|
||||
if server_ids:
|
||||
try:
|
||||
await add_subscription_servers(
|
||||
@@ -524,19 +486,29 @@ class SubscriptionRenewalService:
|
||||
error=error,
|
||||
)
|
||||
|
||||
reset_traffic = was_expired and settings.RESET_TRAFFIC_ON_PAYMENT
|
||||
subscription_service = SubscriptionService()
|
||||
try:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription_after,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason='subscription renewal',
|
||||
)
|
||||
await db.refresh(user)
|
||||
if getattr(user, 'remnawave_uuid', None):
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription_after,
|
||||
reset_traffic=reset_traffic,
|
||||
reset_reason='subscription renewal',
|
||||
)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription_after,
|
||||
reset_traffic=reset_traffic,
|
||||
reset_reason='subscription renewal',
|
||||
)
|
||||
except RemnaWaveConfigurationError as error: # pragma: no cover - configuration issues
|
||||
logger.warning('RemnaWave update skipped', error=error)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
'Failed to update RemnaWave user for subscription',
|
||||
'Failed to sync RemnaWave user for subscription',
|
||||
subscription_after_id=subscription_after.id,
|
||||
error=error,
|
||||
)
|
||||
@@ -583,76 +555,6 @@ class SubscriptionRenewalService:
|
||||
old_end_date=old_end_date,
|
||||
)
|
||||
|
||||
async def _validate_servers_for_user_promo_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
server_uuids: list[str],
|
||||
) -> None:
|
||||
"""
|
||||
Проверяет, что все серверы подписки доступны для промогруппы пользователя.
|
||||
Логирует предупреждения если серверы недоступны.
|
||||
"""
|
||||
if not server_uuids:
|
||||
return
|
||||
|
||||
try:
|
||||
await db.refresh(user, ['user_promo_groups', 'promo_group'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
user_promo_group = user.get_primary_promo_group() if user else None
|
||||
if not user_promo_group:
|
||||
return
|
||||
|
||||
servers = await get_server_squads_by_uuids(db, server_uuids)
|
||||
unavailable_servers = []
|
||||
|
||||
for server in servers:
|
||||
if server.allowed_promo_groups:
|
||||
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
|
||||
if user_promo_group.id not in allowed_ids:
|
||||
unavailable_servers.append(server.display_name or server.squad_uuid)
|
||||
|
||||
if unavailable_servers:
|
||||
user_identifier = user.telegram_id or user.email or f'user#{user.id}'
|
||||
logger.warning(
|
||||
'⚠️ Пользователь (promo_group=) продлевает подписку с серверами, недоступными для его промогруппы: . Это может привести к неправильному расчёту цены!',
|
||||
user_identifier=user_identifier,
|
||||
user_promo_group_name=user_promo_group.name,
|
||||
value=', '.join(unavailable_servers),
|
||||
)
|
||||
|
||||
def build_option_payload(
|
||||
self,
|
||||
pricing: SubscriptionRenewalPricing,
|
||||
*,
|
||||
language: str,
|
||||
) -> dict[str, Any]:
|
||||
label = format_period_description(pricing.period_days, language)
|
||||
price_label = settings.format_price(pricing.final_total)
|
||||
original_label = None
|
||||
if pricing.base_original_total and pricing.base_original_total != pricing.final_total:
|
||||
original_label = settings.format_price(pricing.base_original_total)
|
||||
|
||||
per_month_label = settings.format_price(pricing.per_month)
|
||||
|
||||
payload = {
|
||||
'id': pricing.period_id,
|
||||
'days': pricing.period_days,
|
||||
'months': pricing.months,
|
||||
'price_kopeks': pricing.final_total,
|
||||
'price_label': price_label,
|
||||
'original_price_kopeks': pricing.base_original_total,
|
||||
'original_price_label': original_label,
|
||||
'discount_percent': pricing.overall_discount_percent,
|
||||
'price_per_month_kopeks': pricing.per_month,
|
||||
'price_per_month_label': per_month_label,
|
||||
'title': label,
|
||||
}
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def calculate_missing_amount(balance_kopeks: int, total_kopeks: int) -> int:
|
||||
if total_kopeks <= 0:
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Us
|
||||
from app.external.remnawave_api import RemnaWaveAPI, RemnaWaveAPIError, RemnaWaveUser, TrafficLimitStrategy, UserStatus
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
resolve_discount_percent,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
resolve_hwid_device_limit_for_payload,
|
||||
@@ -23,45 +24,6 @@ from app.utils.subscription_utils import (
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _resolve_discount_percent(
|
||||
user: User | None,
|
||||
promo_group: PromoGroup | None,
|
||||
category: str,
|
||||
*,
|
||||
period_days: int | None = None,
|
||||
) -> int:
|
||||
if user is not None:
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if promo_group is not None:
|
||||
return promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_addon_discount_percent(
|
||||
user: User | None,
|
||||
promo_group: PromoGroup | None,
|
||||
category: str,
|
||||
*,
|
||||
period_days: int | None = None,
|
||||
) -> int:
|
||||
group = promo_group or (user.get_primary_promo_group() if user else None)
|
||||
|
||||
if group is not None and not getattr(group, 'apply_discounts_to_addons', True):
|
||||
return 0
|
||||
|
||||
return _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
category,
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
|
||||
def get_traffic_reset_strategy(tariff=None):
|
||||
"""Получает стратегию сброса трафика.
|
||||
|
||||
@@ -711,284 +673,6 @@ class SubscriptionService:
|
||||
logger.error('Ошибка синхронизации подписки', subscription_id=subscription.id, error=e)
|
||||
return False, 'unknown_error'
|
||||
|
||||
async def calculate_subscription_price(
|
||||
self,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: list[int],
|
||||
devices: int,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: User | None = None,
|
||||
promo_group: PromoGroup | None = None,
|
||||
) -> tuple[int, list[int]]:
|
||||
from app.config import PERIOD_PRICES
|
||||
from app.database.crud.server_squad import get_server_squad_by_id
|
||||
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
|
||||
raise ValueError(f'Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}')
|
||||
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'period',
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
|
||||
|
||||
traffic_price = settings.get_traffic_price(traffic_gb)
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'traffic',
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount = traffic_price * traffic_discount_percent // 100
|
||||
discounted_traffic_price = traffic_price - traffic_discount
|
||||
|
||||
server_prices = []
|
||||
total_servers_price = 0
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'servers',
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available and not server.is_full:
|
||||
server_price = server.price_kopeks
|
||||
server_discount = server_price * servers_discount_percent // 100
|
||||
discounted_server_price = server_price - server_discount
|
||||
server_prices.append(discounted_server_price)
|
||||
total_servers_price += discounted_server_price
|
||||
log_message = f'Сервер {server.display_name}: {server_price / 100}₽'
|
||||
if server_discount > 0:
|
||||
log_message += f' (скидка {servers_discount_percent}%: -{server_discount / 100}₽ → {discounted_server_price / 100}₽)'
|
||||
logger.debug(log_message)
|
||||
else:
|
||||
server_prices.append(0)
|
||||
logger.warning('Сервер ID недоступен', server_id=server_id)
|
||||
|
||||
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'devices',
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount = devices_price * devices_discount_percent // 100
|
||||
discounted_devices_price = devices_price - devices_discount
|
||||
|
||||
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
|
||||
|
||||
logger.debug('Расчет стоимости новой подписки:')
|
||||
base_log = f' Период {period_days} дней: {base_price_original / 100}₽'
|
||||
if base_discount_total > 0:
|
||||
base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)'
|
||||
logger.debug(base_log)
|
||||
if discounted_traffic_price > 0:
|
||||
message = f' Трафик {traffic_gb} ГБ: {traffic_price / 100}₽'
|
||||
if traffic_discount > 0:
|
||||
message += f' (скидка {traffic_discount_percent}%: -{traffic_discount / 100}₽ → {discounted_traffic_price / 100}₽)'
|
||||
logger.debug(message)
|
||||
if total_servers_price > 0:
|
||||
message = f' Серверы ({len(server_squad_ids)}): {total_servers_price / 100}₽'
|
||||
if servers_discount_percent > 0:
|
||||
message += f' (скидка {servers_discount_percent}% применяется ко всем серверам)'
|
||||
logger.debug(message)
|
||||
if discounted_devices_price > 0:
|
||||
message = f' Устройства ({devices}): {devices_price / 100}₽'
|
||||
if devices_discount > 0:
|
||||
message += f' (скидка {devices_discount_percent}%: -{devices_discount / 100}₽ → {discounted_devices_price / 100}₽)'
|
||||
logger.debug(message)
|
||||
logger.debug('ИТОГО: ₽', total_price=total_price / 100)
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
async def calculate_renewal_price(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: User | None = None,
|
||||
promo_group: PromoGroup | None = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, 'user', None)
|
||||
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
|
||||
|
||||
tariff = getattr(subscription, 'tariff', None)
|
||||
tariff_price = tariff.get_price_for_period(period_days) if tariff else None
|
||||
is_tariff_pricing = tariff_price is not None
|
||||
|
||||
if is_tariff_pricing:
|
||||
# --- ТАРИФНЫЙ РЕЖИМ ---
|
||||
# tariff.period_prices — итоговая цена тарифа (серверы, трафик включены).
|
||||
# Сверху добавляем стоимость доп. устройств сверх лимита тарифа.
|
||||
# Порядок: база + устройства → скидка на полную сумму (как в cabinet).
|
||||
original_price = tariff_price
|
||||
|
||||
# Доп. устройства сверх лимита тарифа
|
||||
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
|
||||
device_price_per_unit = (
|
||||
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
device_limit = (
|
||||
subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
|
||||
)
|
||||
extra_devices = max(0, device_limit - tariff_device_limit)
|
||||
months = calculate_months_from_days(period_days)
|
||||
devices_price = extra_devices * device_price_per_unit * months
|
||||
original_price += devices_price
|
||||
|
||||
# Скидка промогруппы на полную сумму (база + устройства)
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'period',
|
||||
period_days=period_days,
|
||||
)
|
||||
discount_total = original_price * period_discount_percent // 100
|
||||
total_price = original_price - discount_total
|
||||
|
||||
# Promo-offer скидка (временная скидка, как в cabinet)
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
promo_offer_percent = get_user_active_promo_discount_percent(user)
|
||||
promo_offer_discount = 0
|
||||
if promo_offer_percent > 0:
|
||||
promo_offer_discount = total_price * promo_offer_percent // 100
|
||||
total_price = total_price - promo_offer_discount
|
||||
|
||||
logger.debug(
|
||||
'💰 Расчет стоимости продления (тариф) для подписки',
|
||||
subscription_id=subscription.id,
|
||||
tariff_name=tariff.name,
|
||||
)
|
||||
base_log = f' 📅 Тариф «{tariff.name}», период {period_days} дней: {tariff_price / 100}₽'
|
||||
if devices_price > 0:
|
||||
base_log += f' + устройства ({extra_devices} сверх {tariff_device_limit}): {devices_price / 100}₽'
|
||||
logger.debug(base_log)
|
||||
if discount_total > 0:
|
||||
logger.debug(f' 🏷️ Скидка промогруппы {period_discount_percent}%: -{discount_total / 100}₽')
|
||||
if promo_offer_discount > 0:
|
||||
logger.debug(f' 🎁 Promo-offer скидка {promo_offer_percent}%: -{promo_offer_discount / 100}₽')
|
||||
logger.debug('💎 ИТОГО: ₽', total_price=total_price / 100)
|
||||
|
||||
else:
|
||||
# --- КЛАССИК РЕЖИМ ---
|
||||
# base (PERIOD_PRICES) + серверы + трафик + устройства
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
servers_price, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group.id if promo_group else None,
|
||||
)
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'servers',
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount = servers_price * servers_discount_percent // 100
|
||||
discounted_servers_price = servers_price - servers_discount
|
||||
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
device_limit = forced_limit if forced_limit is not None else settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
devices_price = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'devices',
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount = devices_price * devices_discount_percent // 100
|
||||
discounted_devices_price = devices_price - devices_discount
|
||||
|
||||
# Трафик: вычитаем purchased_traffic_gb чтобы не завышать тир
|
||||
purchased_traffic = subscription.purchased_traffic_gb or 0
|
||||
if settings.is_traffic_fixed():
|
||||
renewal_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
elif purchased_traffic > 0:
|
||||
base_traffic = (subscription.traffic_limit_gb or 0) - purchased_traffic
|
||||
renewal_traffic_gb = base_traffic if base_traffic > 0 else subscription.traffic_limit_gb
|
||||
else:
|
||||
renewal_traffic_gb = subscription.traffic_limit_gb
|
||||
|
||||
traffic_price = settings.get_traffic_price(renewal_traffic_gb)
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'traffic',
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount = traffic_price * traffic_discount_percent // 100
|
||||
discounted_traffic_price = traffic_price - traffic_discount
|
||||
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'period',
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
total_price = (
|
||||
base_price + discounted_servers_price + discounted_devices_price + discounted_traffic_price
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
'💰 Расчет стоимости продления (классик) для подписки',
|
||||
subscription_id=subscription.id,
|
||||
)
|
||||
base_log = f' 📅 Период {period_days} дней: {base_price_original / 100}₽'
|
||||
if base_discount_total > 0:
|
||||
base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%)'
|
||||
logger.debug(base_log)
|
||||
if servers_price > 0:
|
||||
message = f' 🌍 Серверы ({len(subscription.connected_squads)}): {discounted_servers_price / 100}₽'
|
||||
if servers_discount > 0:
|
||||
message += f' (скидка {servers_discount_percent}%: -{servers_discount / 100}₽)'
|
||||
logger.debug(message)
|
||||
if devices_price > 0:
|
||||
message = f' 📱 Устройства ({device_limit}): {discounted_devices_price / 100}₽'
|
||||
if devices_discount > 0:
|
||||
message += f' (скидка {devices_discount_percent}%: -{devices_discount / 100}₽)'
|
||||
logger.debug(message)
|
||||
if traffic_price > 0:
|
||||
message = f' 📊 Трафик ({renewal_traffic_gb} ГБ): {discounted_traffic_price / 100}₽'
|
||||
if traffic_discount > 0:
|
||||
message += f' (скидка {traffic_discount_percent}%: -{traffic_discount / 100}₽)'
|
||||
logger.debug(message)
|
||||
logger.debug('💎 ИТОГО: ₽', total_price=total_price / 100)
|
||||
|
||||
return total_price
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка расчета стоимости продления', error=e, exc_info=True)
|
||||
# Не возвращаем 0 — это приведёт к бесплатному продлению.
|
||||
# Пробрасываем ошибку, чтобы вызывающий код решал что делать.
|
||||
raise
|
||||
|
||||
async def validate_and_clean_subscription(self, db: AsyncSession, subscription: Subscription, user: User) -> bool:
|
||||
try:
|
||||
needs_cleanup = False
|
||||
@@ -1091,14 +775,6 @@ class SubscriptionService:
|
||||
default_prices = [0] * len(country_uuids)
|
||||
return sum(default_prices), default_prices
|
||||
|
||||
async def _get_countries_price(self, country_uuids: list[str], db: AsyncSession) -> int:
|
||||
try:
|
||||
total_price, _ = await self.get_countries_price_by_uuids(country_uuids, db)
|
||||
return total_price
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения цен стран', error=e)
|
||||
return len(country_uuids) * 1000
|
||||
|
||||
async def calculate_subscription_price_with_months(
|
||||
self,
|
||||
period_days: int,
|
||||
@@ -1119,7 +795,7 @@ class SubscriptionService:
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
period_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'period',
|
||||
@@ -1131,7 +807,7 @@ class SubscriptionService:
|
||||
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
traffic_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'traffic',
|
||||
@@ -1143,7 +819,7 @@ class SubscriptionService:
|
||||
|
||||
server_prices = []
|
||||
total_servers_price = 0
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
servers_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'servers',
|
||||
@@ -1171,7 +847,7 @@ class SubscriptionService:
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
devices_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'devices',
|
||||
@@ -1213,278 +889,6 @@ class SubscriptionService:
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
async def calculate_renewal_price_with_months(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: User | None = None,
|
||||
promo_group: PromoGroup | None = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, 'user', None)
|
||||
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
|
||||
|
||||
tariff = getattr(subscription, 'tariff', None)
|
||||
tariff_price = tariff.get_price_for_period(period_days) if tariff else None
|
||||
is_tariff_pricing = tariff_price is not None
|
||||
|
||||
if is_tariff_pricing:
|
||||
# --- ТАРИФНЫЙ РЕЖИМ ---
|
||||
# Порядок: база + устройства → скидка на полную сумму (как в cabinet).
|
||||
original_price = tariff_price
|
||||
|
||||
# Доп. устройства сверх лимита тарифа
|
||||
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
|
||||
device_price_per_unit = (
|
||||
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
device_limit = (
|
||||
subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
|
||||
)
|
||||
extra_devices = max(0, device_limit - tariff_device_limit)
|
||||
devices_price_total = extra_devices * device_price_per_unit * months_in_period
|
||||
original_price += devices_price_total
|
||||
|
||||
# Скидка промогруппы на полную сумму (база + устройства)
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'period',
|
||||
period_days=period_days,
|
||||
)
|
||||
discount_total = original_price * period_discount_percent // 100
|
||||
total_price = original_price - discount_total
|
||||
|
||||
# Promo-offer скидка (временная скидка, как в cabinet)
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
promo_offer_percent = get_user_active_promo_discount_percent(user)
|
||||
promo_offer_discount = 0
|
||||
if promo_offer_percent > 0:
|
||||
promo_offer_discount = total_price * promo_offer_percent // 100
|
||||
total_price = total_price - promo_offer_discount
|
||||
|
||||
logger.debug(
|
||||
'💰 Расчет стоимости продления (тариф) на дней ( мес)',
|
||||
subscription_id=subscription.id,
|
||||
period_days=period_days,
|
||||
months_in_period=months_in_period,
|
||||
tariff_name=tariff.name,
|
||||
)
|
||||
base_log = f' 📅 Тариф «{tariff.name}», период {period_days} дней: {tariff_price / 100}₽'
|
||||
if devices_price_total > 0:
|
||||
base_log += (
|
||||
f' + устройства ({extra_devices} сверх {tariff_device_limit}): {devices_price_total / 100}₽'
|
||||
)
|
||||
logger.debug(base_log)
|
||||
if discount_total > 0:
|
||||
logger.debug(f' 🏷️ Скидка промогруппы {period_discount_percent}%: -{discount_total / 100}₽')
|
||||
if promo_offer_discount > 0:
|
||||
logger.debug(f' 🎁 Promo-offer скидка {promo_offer_percent}%: -{promo_offer_discount / 100}₽')
|
||||
logger.debug('💎 ИТОГО: ₽', total_price=total_price / 100)
|
||||
|
||||
else:
|
||||
# --- КЛАССИК РЕЖИМ ---
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group.id if promo_group else None,
|
||||
)
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'servers',
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_price = discounted_servers_per_month * months_in_period
|
||||
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
device_limit = forced_limit if forced_limit is not None else settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'devices',
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_per_month * months_in_period
|
||||
|
||||
# Трафик: вычитаем purchased_traffic_gb чтобы не завышать тир
|
||||
purchased_traffic = subscription.purchased_traffic_gb or 0
|
||||
if settings.is_traffic_fixed():
|
||||
renewal_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
elif purchased_traffic > 0:
|
||||
base_traffic = (subscription.traffic_limit_gb or 0) - purchased_traffic
|
||||
renewal_traffic_gb = base_traffic if base_traffic > 0 else subscription.traffic_limit_gb
|
||||
else:
|
||||
renewal_traffic_gb = subscription.traffic_limit_gb
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb)
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'traffic',
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'period',
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
|
||||
logger.debug(
|
||||
'💰 Расчет стоимости продления (классик) на дней ( мес)',
|
||||
subscription_id=subscription.id,
|
||||
period_days=period_days,
|
||||
months_in_period=months_in_period,
|
||||
)
|
||||
base_log = f' 📅 Период {period_days} дней: {base_price_original / 100}₽'
|
||||
if base_discount_total > 0:
|
||||
base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%)'
|
||||
logger.debug(base_log)
|
||||
if total_servers_price > 0:
|
||||
message = f' 🌍 Серверы: {servers_price_per_month / 100}₽/мес x {months_in_period} = {total_servers_price / 100}₽'
|
||||
if servers_discount_per_month > 0:
|
||||
message += f' (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period / 100}₽)'
|
||||
logger.debug(message)
|
||||
if total_devices_price > 0:
|
||||
message = f' 📱 Устройства: {devices_price_per_month / 100}₽/мес x {months_in_period} = {total_devices_price / 100}₽'
|
||||
if devices_discount_per_month > 0:
|
||||
message += f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period / 100}₽)'
|
||||
logger.debug(message)
|
||||
if total_traffic_price > 0:
|
||||
message = f' 📊 Трафик: {traffic_price_per_month / 100}₽/мес x {months_in_period} = {total_traffic_price / 100}₽'
|
||||
if traffic_discount_per_month > 0:
|
||||
message += f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period / 100}₽)'
|
||||
logger.debug(message)
|
||||
logger.debug('💎 ИТОГО: ₽', total_price=total_price / 100)
|
||||
|
||||
return total_price
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка расчета стоимости продления (with_months)', error=e, exc_info=True)
|
||||
raise
|
||||
|
||||
async def calculate_addon_price_with_remaining_period(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
additional_traffic_gb: int = 0,
|
||||
additional_devices: int = 0,
|
||||
additional_server_ids: list[int] = None,
|
||||
db: AsyncSession = None,
|
||||
) -> int:
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
now = datetime.now(UTC)
|
||||
days_to_pay = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_to_pay
|
||||
|
||||
user = getattr(subscription, 'user', None)
|
||||
promo_group = user.promo_group if user else None
|
||||
|
||||
total_price = 0
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
traffic_discount_percent = _resolve_addon_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'traffic',
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
traffic_total_price = int(discounted_traffic_per_month * days_to_pay / 30)
|
||||
total_price += traffic_total_price
|
||||
message = (
|
||||
f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес x {days_to_pay} дн.'
|
||||
f' = {traffic_total_price / 100}₽'
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += f' (скидка {traffic_discount_percent}%: -{int(traffic_discount_per_month * days_to_pay / 30) / 100}₽)'
|
||||
logger.info(message)
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _resolve_addon_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'devices',
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
devices_total_price = int(discounted_devices_per_month * days_to_pay / 30)
|
||||
total_price += devices_total_price
|
||||
message = (
|
||||
f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес x {days_to_pay} дн.'
|
||||
f' = {devices_total_price / 100}₽'
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += f' (скидка {devices_discount_percent}%: -{int(devices_discount_per_month * days_to_pay / 30) / 100}₽)'
|
||||
logger.info(message)
|
||||
|
||||
if additional_server_ids and db:
|
||||
for server_id in additional_server_ids:
|
||||
from app.database.crud.server_squad import get_server_squad_by_id
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available:
|
||||
server_price_per_month = server.price_kopeks
|
||||
servers_discount_percent = _resolve_addon_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'servers',
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
|
||||
discounted_server_per_month = server_price_per_month - server_discount_per_month
|
||||
server_total_price = int(discounted_server_per_month * days_to_pay / 30)
|
||||
total_price += server_total_price
|
||||
message = (
|
||||
f'Сервер {server.display_name}: {server_price_per_month / 100}₽/мес x {days_to_pay} дн.'
|
||||
f' = {server_total_price / 100}₽'
|
||||
)
|
||||
if server_discount_per_month > 0:
|
||||
message += (
|
||||
f' (скидка {servers_discount_percent}%:'
|
||||
f' -{int(server_discount_per_month * days_to_pay / 30) / 100}₽)'
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
logger.info('Итого доплата за дн.: ₽', days_to_pay=days_to_pay, total_price=total_price / 100)
|
||||
return total_price
|
||||
|
||||
def _gb_to_bytes(self, gb: int | None) -> int:
|
||||
if not gb: # None or 0
|
||||
return 0
|
||||
|
||||
@@ -141,6 +141,11 @@ class TributeService:
|
||||
description=f'Пополнение через Tribute: {amount_kopeks / 100}₽ (ID: {payment_id})',
|
||||
)
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(session, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
@@ -247,12 +252,19 @@ class TributeService:
|
||||
payment_method=PaymentMethod.TRIBUTE,
|
||||
external_id=f'refund_{payment_id}',
|
||||
is_completed=True,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
if user and user.balance_kopeks >= amount_kopeks:
|
||||
user.balance_kopeks -= amount_kopeks
|
||||
await session.commit()
|
||||
if user:
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(session, user)
|
||||
if user.balance_kopeks >= amount_kopeks:
|
||||
user.balance_kopeks -= amount_kopeks
|
||||
|
||||
await session.commit()
|
||||
|
||||
await self._send_refund_notification(user_id, amount_kopeks)
|
||||
|
||||
@@ -394,6 +406,11 @@ class TributeService:
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(session, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
@@ -1246,10 +1246,14 @@ class UserService:
|
||||
AccessPolicy,
|
||||
AdminAuditLog,
|
||||
AdminRole,
|
||||
RioPayPayment,
|
||||
SavedPaymentMethod,
|
||||
UserRole,
|
||||
WithdrawalRequest,
|
||||
)
|
||||
|
||||
await db.execute(delete(SavedPaymentMethod).where(SavedPaymentMethod.user_id == user_id))
|
||||
await db.execute(delete(RioPayPayment).where(RioPayPayment.user_id == user_id))
|
||||
await db.execute(delete(AdminAuditLog).where(AdminAuditLog.user_id == user_id))
|
||||
await db.execute(delete(WithdrawalRequest).where(WithdrawalRequest.user_id == user_id))
|
||||
await db.execute(
|
||||
|
||||
@@ -15,6 +15,10 @@ from app.config import settings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# WATA API rejects expirationDateTime <= now + 10 minutes (exclusive lower bound).
|
||||
# 15 minutes provides a 5-minute buffer against clock skew and request latency.
|
||||
_MIN_EXPIRATION_MINUTES = 15
|
||||
|
||||
|
||||
class WataAPIError(RuntimeError):
|
||||
"""Raised when the WATA API returns an error response."""
|
||||
@@ -194,7 +198,10 @@ class WataService:
|
||||
expiration_minutes = int(ttl) if ttl is not None else None
|
||||
|
||||
if expiration_minutes:
|
||||
expiration_time = datetime.now(UTC) + timedelta(minutes=expiration_minutes)
|
||||
# WATA API требует expirationDateTime строго > now + 10 минут.
|
||||
# Принудительный минимум 15 минут, чтобы не попасть на границу.
|
||||
safe_minutes = max(expiration_minutes, _MIN_EXPIRATION_MINUTES)
|
||||
expiration_time = datetime.now(UTC) + timedelta(minutes=safe_minutes)
|
||||
payload['expirationDateTime'] = self._format_datetime(expiration_time)
|
||||
|
||||
if allow_arbitrary_amount:
|
||||
|
||||
@@ -276,6 +276,10 @@ class FortuneWheelService:
|
||||
rubles = Decimal(config.spin_cost_stars) * stars_rate
|
||||
kopeks = int(rubles * 100)
|
||||
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
if user.balance_kopeks < kopeks:
|
||||
raise ValueError('Недостаточно средств на балансе')
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Shared formatting utilities for traffic, price, and period display."""
|
||||
|
||||
|
||||
def format_traffic(gb: int) -> str:
|
||||
"""Форматирует трафик."""
|
||||
if gb == 0:
|
||||
return 'Безлимит'
|
||||
return f'{gb} ГБ'
|
||||
|
||||
|
||||
def format_price_kopeks(kopeks: int, compact: bool = False) -> str:
|
||||
"""Форматирует цену из копеек в рубли."""
|
||||
rubles = kopeks / 100
|
||||
if compact:
|
||||
# Компактный формат - округляем до рублей
|
||||
return f'{int(round(rubles))}₽'
|
||||
if rubles == int(rubles):
|
||||
return f'{int(rubles)} ₽'
|
||||
return f'{rubles:.2f} ₽'
|
||||
|
||||
|
||||
def format_period(days: int) -> str:
|
||||
"""Форматирует период."""
|
||||
mod100 = days % 100
|
||||
mod10 = days % 10
|
||||
if 11 <= mod100 <= 19:
|
||||
word = 'дней'
|
||||
elif mod10 == 1:
|
||||
word = 'день'
|
||||
elif 2 <= mod10 <= 4:
|
||||
word = 'дня'
|
||||
else:
|
||||
word = 'дней'
|
||||
return f'{days} {word}'
|
||||
+17
-51
@@ -18,20 +18,6 @@ def calculate_months_from_days(days: int) -> int:
|
||||
return max(1, round(days / 30))
|
||||
|
||||
|
||||
def calculate_period_multiplier(period_days: int) -> tuple[int, float]:
|
||||
exact_months = period_days / 30
|
||||
months_count = max(1, round(exact_months))
|
||||
|
||||
logger.debug(
|
||||
'Период дней точных месяцев ≈ месяцев для расчета',
|
||||
period_days=period_days,
|
||||
exact_months=round(exact_months, 2),
|
||||
months_count=months_count,
|
||||
)
|
||||
|
||||
return months_count, exact_months
|
||||
|
||||
|
||||
def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_days: int = 30) -> tuple[int, int]:
|
||||
"""Calculate prorated price based on remaining days.
|
||||
|
||||
@@ -42,7 +28,7 @@ def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_
|
||||
days_remaining = max(1, (end_date - now).days)
|
||||
days_to_charge = max(min_charge_days, days_remaining)
|
||||
|
||||
total_price = int(monthly_price * days_to_charge / 30)
|
||||
total_price = monthly_price * days_to_charge // 30
|
||||
if monthly_price > 0:
|
||||
total_price = max(100, total_price) # Минимум 1 рубль
|
||||
|
||||
@@ -57,29 +43,17 @@ def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_
|
||||
|
||||
|
||||
def apply_percentage_discount(amount: int, percent: int) -> tuple[int, int]:
|
||||
"""Apply percentage discount using PricingEngine's floor division.
|
||||
|
||||
Returns (discounted_amount, discount_value).
|
||||
"""
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
if amount <= 0 or percent <= 0:
|
||||
return amount, 0
|
||||
|
||||
clamped_percent = max(0, min(100, percent))
|
||||
discount_value = amount * clamped_percent // 100
|
||||
discounted_amount = amount - discount_value
|
||||
|
||||
# Round the discounted price up to the nearest full ruble (100 kopeks)
|
||||
# to avoid undercharging users because of fractional kopeks.
|
||||
if discount_value >= 100 and discounted_amount % 100:
|
||||
discounted_amount += 100 - (discounted_amount % 100)
|
||||
discounted_amount = min(discounted_amount, amount)
|
||||
discount_value = amount - discounted_amount
|
||||
|
||||
logger.debug(
|
||||
'Применена скидка %: → (скидка)',
|
||||
clamped_percent=clamped_percent,
|
||||
amount=amount,
|
||||
discounted_amount=discounted_amount,
|
||||
discount_value=discount_value,
|
||||
)
|
||||
|
||||
return discounted_amount, discount_value
|
||||
discounted = PricingEngine.apply_discount(amount, percent)
|
||||
return discounted, amount - discounted
|
||||
|
||||
|
||||
def resolve_discount_percent(
|
||||
@@ -189,14 +163,20 @@ async def compute_simple_subscription_price(
|
||||
elif raw_squad:
|
||||
resolved_uuids.append(str(raw_squad))
|
||||
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
from app.database.crud.server_squad import get_server_squads_by_uuids
|
||||
|
||||
server_breakdown: list[dict[str, Any]] = []
|
||||
servers_price_original = 0
|
||||
servers_discount_total = 0
|
||||
|
||||
if resolved_uuids:
|
||||
servers = await get_server_squads_by_uuids(db, resolved_uuids)
|
||||
server_map = {s.squad_uuid: s for s in servers}
|
||||
else:
|
||||
server_map = {}
|
||||
|
||||
for squad_uuid in resolved_uuids:
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
server = server_map.get(squad_uuid)
|
||||
if not server:
|
||||
logger.warning('SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad', squad_uuid=squad_uuid)
|
||||
server_breakdown.append(
|
||||
@@ -340,17 +320,3 @@ def validate_pricing_calculation(base_price: int, monthly_additions: int, months
|
||||
)
|
||||
|
||||
return is_valid
|
||||
|
||||
|
||||
STANDARD_PERIODS = {
|
||||
14: {'months': 0.5, 'display_ru': '2 недели', 'display_en': '2 weeks'},
|
||||
30: {'months': 1, 'display_ru': '1 месяц', 'display_en': '1 month'},
|
||||
60: {'months': 2, 'display_ru': '2 месяца', 'display_en': '2 months'},
|
||||
90: {'months': 3, 'display_ru': '3 месяца', 'display_en': '3 months'},
|
||||
180: {'months': 6, 'display_ru': '6 месяцев', 'display_en': '6 months'},
|
||||
360: {'months': 12, 'display_ru': '1 год', 'display_en': '1 year'},
|
||||
}
|
||||
|
||||
|
||||
def get_period_info(days: int) -> dict:
|
||||
return STANDARD_PERIODS.get(days)
|
||||
|
||||
+206
-322
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Collection
|
||||
@@ -2867,10 +2868,9 @@ async def _build_referral_info(
|
||||
referral_code = getattr(user, 'referral_code', None)
|
||||
referral_settings = settings.get_referral_settings() or {}
|
||||
|
||||
bot_username = settings.get_bot_username()
|
||||
referral_link = None
|
||||
if referral_code and bot_username:
|
||||
referral_link = f'https://t.me/{bot_username}?start={referral_code}'
|
||||
if referral_code:
|
||||
referral_link = settings.get_referral_link(referral_code)
|
||||
|
||||
minimum_topup_kopeks = int(referral_settings.get('minimum_topup_kopeks') or 0)
|
||||
first_topup_bonus_kopeks = int(referral_settings.get('first_topup_bonus_kopeks') or 0)
|
||||
@@ -4498,150 +4498,94 @@ def _parse_period_identifier(identifier: str | None) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _calculate_subscription_renewal_pricing(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
):
|
||||
return await renewal_service.calculate_pricing(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
period_days,
|
||||
)
|
||||
|
||||
|
||||
async def _prepare_subscription_renewal_options(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
) -> tuple[list[MiniAppSubscriptionRenewalPeriod], dict[str | int, dict[str, Any]], str | None]:
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
option_payloads: list[tuple[MiniAppSubscriptionRenewalPeriod, dict[str, Any]]] = []
|
||||
|
||||
# Проверяем, есть ли у подписки тариф (режим тарифов)
|
||||
# Определяем доступные периоды: из тарифа или из настроек
|
||||
tariff_id = getattr(subscription, 'tariff_id', None)
|
||||
tariff = None
|
||||
if tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
|
||||
if tariff and tariff.period_prices:
|
||||
# Режим тарифов: используем периоды и цены из тарифа
|
||||
promo_group = (
|
||||
user.get_primary_promo_group()
|
||||
if hasattr(user, 'get_primary_promo_group')
|
||||
else getattr(user, 'promo_group', None)
|
||||
available_periods = sorted(int(k) for k in tariff.period_prices.keys())
|
||||
else:
|
||||
available_periods = [p for p in settings.get_available_renewal_periods() if p > 0]
|
||||
|
||||
for period_days in available_periods:
|
||||
try:
|
||||
pricing_result = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
subscription,
|
||||
period_days,
|
||||
user=user,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to calculate renewal pricing for subscription (period)',
|
||||
subscription_id=subscription.id,
|
||||
period_days=period_days,
|
||||
error=error,
|
||||
)
|
||||
continue
|
||||
|
||||
# Вычисляем оригинальную цену (до скидок) для отображения зачёркнутой цены
|
||||
original_price = pricing_result.original_total
|
||||
has_discount = original_price > pricing_result.final_total and original_price > 0
|
||||
discount_percent = (
|
||||
int((original_price - pricing_result.final_total) * 100 / original_price) if has_discount else 0
|
||||
)
|
||||
|
||||
# Получаем скидки промогруппы по периодам
|
||||
period_discounts = {}
|
||||
if promo_group:
|
||||
raw_discounts = getattr(promo_group, 'period_discounts', None) or {}
|
||||
for k, v in raw_discounts.items():
|
||||
try:
|
||||
period_discounts[int(k)] = max(0, min(100, int(v)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
months = max(1, period_days // 30)
|
||||
per_month = pricing_result.final_total // months if months > 0 else pricing_result.final_total
|
||||
|
||||
for period_str, original_price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])):
|
||||
period_days = int(period_str)
|
||||
label = format_period_description(
|
||||
period_days,
|
||||
getattr(user, 'language', settings.DEFAULT_LANGUAGE),
|
||||
)
|
||||
|
||||
# Применяем скидку промогруппы
|
||||
discount_percent = period_discounts.get(period_days, 0)
|
||||
if discount_percent > 0:
|
||||
price_kopeks = int(original_price_kopeks * (100 - discount_percent) / 100)
|
||||
else:
|
||||
price_kopeks = original_price_kopeks
|
||||
price_label = settings.format_price(pricing_result.final_total)
|
||||
original_label = settings.format_price(original_price) if has_discount else None
|
||||
per_month_label = settings.format_price(per_month)
|
||||
|
||||
months = max(1, period_days // 30)
|
||||
per_month = price_kopeks // months if months > 0 else price_kopeks
|
||||
period_id = (
|
||||
f'tariff_{tariff.id}_{period_days}' if pricing_result.is_tariff_mode and tariff else f'days:{period_days}'
|
||||
)
|
||||
|
||||
label = format_period_description(
|
||||
period_days,
|
||||
getattr(user, 'language', settings.DEFAULT_LANGUAGE),
|
||||
)
|
||||
option_model = MiniAppSubscriptionRenewalPeriod(
|
||||
id=period_id,
|
||||
days=period_days,
|
||||
months=months,
|
||||
price_kopeks=pricing_result.final_total,
|
||||
price_label=price_label,
|
||||
original_price_kopeks=original_price if has_discount else None,
|
||||
original_price_label=original_label,
|
||||
discount_percent=discount_percent,
|
||||
price_per_month_kopeks=per_month,
|
||||
price_per_month_label=per_month_label,
|
||||
title=label,
|
||||
)
|
||||
|
||||
price_label = settings.format_price(price_kopeks)
|
||||
original_label = settings.format_price(original_price_kopeks) if discount_percent > 0 else None
|
||||
per_month_label = settings.format_price(per_month)
|
||||
pricing = {
|
||||
'period_id': period_id,
|
||||
'period_days': period_days,
|
||||
'months': months,
|
||||
'final_total': pricing_result.final_total,
|
||||
'base_original_total': original_price if has_discount else pricing_result.final_total,
|
||||
'overall_discount_percent': discount_percent,
|
||||
'per_month': per_month,
|
||||
'promo_offer_discount': pricing_result.promo_offer_discount,
|
||||
}
|
||||
if pricing_result.is_tariff_mode and tariff:
|
||||
pricing['tariff_id'] = tariff.id
|
||||
|
||||
option_model = MiniAppSubscriptionRenewalPeriod(
|
||||
id=f'tariff_{tariff.id}_{period_days}',
|
||||
days=period_days,
|
||||
months=months,
|
||||
price_kopeks=price_kopeks,
|
||||
price_label=price_label,
|
||||
original_price_kopeks=original_price_kopeks if discount_percent > 0 else None,
|
||||
original_price_label=original_label,
|
||||
discount_percent=discount_percent,
|
||||
price_per_month_kopeks=per_month,
|
||||
price_per_month_label=per_month_label,
|
||||
title=label,
|
||||
)
|
||||
|
||||
pricing = {
|
||||
'period_id': option_model.id,
|
||||
'period_days': period_days,
|
||||
'months': months,
|
||||
'final_total': price_kopeks,
|
||||
'base_original_total': original_price_kopeks if discount_percent > 0 else price_kopeks,
|
||||
'overall_discount_percent': discount_percent,
|
||||
'per_month': per_month,
|
||||
'tariff_id': tariff.id,
|
||||
}
|
||||
|
||||
option_payloads.append((option_model, pricing))
|
||||
else:
|
||||
# Классический режим: используем периоды из настроек
|
||||
available_periods = [period for period in settings.get_available_renewal_periods() if period > 0]
|
||||
|
||||
for period_days in available_periods:
|
||||
try:
|
||||
pricing_model = await _calculate_subscription_renewal_pricing(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
period_days,
|
||||
)
|
||||
pricing = pricing_model.to_payload()
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to calculate renewal pricing for subscription (period)',
|
||||
subscription_id=subscription.id,
|
||||
period_days=period_days,
|
||||
error=error,
|
||||
)
|
||||
continue
|
||||
|
||||
label = format_period_description(
|
||||
period_days,
|
||||
getattr(user, 'language', settings.DEFAULT_LANGUAGE),
|
||||
)
|
||||
|
||||
price_label = settings.format_price(pricing['final_total'])
|
||||
original_label = None
|
||||
if pricing['base_original_total'] and pricing['base_original_total'] != pricing['final_total']:
|
||||
original_label = settings.format_price(pricing['base_original_total'])
|
||||
|
||||
per_month_label = settings.format_price(pricing['per_month'])
|
||||
|
||||
option_model = MiniAppSubscriptionRenewalPeriod(
|
||||
id=pricing['period_id'],
|
||||
days=period_days,
|
||||
months=pricing['months'],
|
||||
price_kopeks=pricing['final_total'],
|
||||
price_label=price_label,
|
||||
original_price_kopeks=pricing['base_original_total'],
|
||||
original_price_label=original_label,
|
||||
discount_percent=pricing['overall_discount_percent'],
|
||||
price_per_month_kopeks=pricing['per_month'],
|
||||
price_per_month_label=per_month_label,
|
||||
title=label,
|
||||
)
|
||||
|
||||
option_payloads.append((option_model, pricing))
|
||||
option_payloads.append((option_model, pricing))
|
||||
|
||||
if not option_payloads:
|
||||
return [], {}, None
|
||||
@@ -4816,6 +4760,11 @@ def _ensure_paid_subscription(
|
||||
'code': 'subscription_disabled',
|
||||
'message': 'Subscription is disabled',
|
||||
}
|
||||
elif actual_status == 'limited':
|
||||
detail = {
|
||||
'code': 'traffic_exhausted',
|
||||
'message': 'Traffic limit reached. Please purchase additional traffic.',
|
||||
}
|
||||
else:
|
||||
detail = {
|
||||
'code': 'subscription_inactive',
|
||||
@@ -5218,18 +5167,15 @@ async def submit_subscription_renewal_endpoint(
|
||||
detail={'code': 'invalid_period', 'message': 'Invalid renewal period'},
|
||||
)
|
||||
|
||||
# Проверяем, есть ли у подписки тариф (режим тарифов)
|
||||
# Валидация периода и расчёт цены через PricingEngine
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
tariff_id = getattr(subscription, 'tariff_id', None)
|
||||
tariff = None
|
||||
tariff_pricing = None
|
||||
|
||||
if tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
|
||||
if tariff and tariff.period_prices:
|
||||
# Режим тарифов: проверяем периоды из тарифа
|
||||
available_periods = [int(p) for p in tariff.period_prices.keys()]
|
||||
if period_days not in available_periods:
|
||||
raise HTTPException(
|
||||
@@ -5239,200 +5185,75 @@ async def submit_subscription_renewal_endpoint(
|
||||
'message': 'Selected renewal period is not available for this tariff',
|
||||
},
|
||||
)
|
||||
|
||||
# Рассчитываем цену из тарифа
|
||||
original_price_kopeks = tariff.period_prices.get(str(period_days), tariff.period_prices.get(period_days, 0))
|
||||
|
||||
# Добавляем стоимость докупленных устройств сверх тарифа (ДО скидки, как в cabinet)
|
||||
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
|
||||
sub_device_limit = subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
|
||||
extra_devices = max(0, sub_device_limit - tariff_device_limit)
|
||||
if extra_devices > 0:
|
||||
from app.utils.pricing_utils import calculate_months_from_days
|
||||
|
||||
device_price = (
|
||||
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
months = calculate_months_from_days(period_days)
|
||||
original_price_kopeks += extra_devices * device_price * months
|
||||
|
||||
# Применяем скидку промогруппы (к полной сумме: тариф + доп. устройства)
|
||||
discount_percent = 0
|
||||
if hasattr(user, 'get_promo_discount'):
|
||||
discount_percent = user.get_promo_discount('period', period_days)
|
||||
|
||||
final_total = original_price_kopeks
|
||||
if discount_percent > 0:
|
||||
final_total = int(original_price_kopeks * (100 - discount_percent) / 100)
|
||||
|
||||
# Применяем promo_offer скидку (временная скидка, как в cabinet)
|
||||
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
if promo_offer_discount_percent > 0:
|
||||
promo_offer_discount_value = final_total * promo_offer_discount_percent // 100
|
||||
final_total = final_total - promo_offer_discount_value
|
||||
|
||||
# Комбинированный процент скидки для отображения
|
||||
combined_discount_percent = discount_percent
|
||||
if promo_offer_discount_percent > 0 and original_price_kopeks > 0:
|
||||
total_discount = original_price_kopeks - final_total
|
||||
combined_discount_percent = int(total_discount * 100 / original_price_kopeks)
|
||||
|
||||
tariff_pricing = {
|
||||
'period_days': period_days,
|
||||
'original_price_kopeks': original_price_kopeks,
|
||||
'discount_percent': combined_discount_percent,
|
||||
'final_total': final_total,
|
||||
'tariff_id': tariff.id,
|
||||
}
|
||||
else:
|
||||
# Классический режим
|
||||
available_periods = [period for period in settings.get_available_renewal_periods() if period > 0]
|
||||
available_periods = [p for p in settings.get_available_renewal_periods() if p > 0]
|
||||
if period_days not in available_periods:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={'code': 'period_unavailable', 'message': 'Selected renewal period is not available'},
|
||||
)
|
||||
|
||||
try:
|
||||
pricing_result = await pricing_engine.calculate_renewal_price(db, subscription, period_days, user=user)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Failed to calculate renewal pricing for subscription (period)',
|
||||
subscription_id=subscription.id,
|
||||
period_days=period_days,
|
||||
error=error,
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail={'code': 'pricing_failed', 'message': 'Failed to calculate renewal pricing'},
|
||||
) from error
|
||||
|
||||
final_total = pricing_result.final_total
|
||||
promo_offer_discount_value = pricing_result.promo_offer_discount
|
||||
|
||||
method = (payload.method or '').strip().lower()
|
||||
|
||||
# Для тарифного режима используем упрощённый расчёт
|
||||
if tariff_pricing:
|
||||
final_total = tariff_pricing['final_total']
|
||||
pricing = tariff_pricing
|
||||
else:
|
||||
try:
|
||||
pricing_model = await _calculate_subscription_renewal_pricing(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
period_days,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Failed to calculate renewal pricing for subscription (period)',
|
||||
subscription_id=subscription.id,
|
||||
period_days=period_days,
|
||||
error=error,
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail={'code': 'pricing_failed', 'message': 'Failed to calculate renewal pricing'},
|
||||
) from error
|
||||
|
||||
pricing = pricing_model.to_payload()
|
||||
final_total = int(pricing_model.final_total)
|
||||
balance_kopeks = getattr(user, 'balance_kopeks', 0)
|
||||
missing_amount = calculate_missing_amount(balance_kopeks, final_total)
|
||||
description = f'Продление подписки на {period_days} дней'
|
||||
|
||||
if missing_amount <= 0:
|
||||
if tariff_pricing:
|
||||
# Тарифный режим: простое продление
|
||||
from app.database.crud.subscription import extend_subscription
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
try:
|
||||
# Списываем баланс (subtract_user_balance делает commit и обновляет user.balance_kopeks)
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
final_total,
|
||||
description,
|
||||
consume_promo_offer=promo_offer_discount_percent > 0,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={'code': 'balance_error', 'message': 'Failed to subtract balance'},
|
||||
)
|
||||
|
||||
# Продлеваем подписку
|
||||
subscription = await extend_subscription(db, subscription, period_days)
|
||||
new_end_date = subscription.end_date
|
||||
|
||||
# Записываем транзакцию
|
||||
from app.database.models import TransactionType
|
||||
|
||||
await create_transaction(
|
||||
db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=final_total,
|
||||
description=description,
|
||||
)
|
||||
|
||||
# Синхронизируем с RemnaWave (сброс трафика по настройке)
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
service = SubscriptionService()
|
||||
await service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason='subscription renewal (miniapp)',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка синхронизации с RemnaWave при продлении (miniapp)', error=e)
|
||||
|
||||
lang = getattr(user, 'language', settings.DEFAULT_LANGUAGE)
|
||||
if lang == 'ru':
|
||||
message = f'Подписка продлена до {new_end_date.strftime("%d.%m.%Y")}'
|
||||
else:
|
||||
message = f'Subscription extended until {new_end_date.strftime("%Y-%m-%d")}'
|
||||
|
||||
return MiniAppSubscriptionRenewalResponse(
|
||||
message=message,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
balance_label=settings.format_price(user.balance_kopeks),
|
||||
subscription_id=subscription.id,
|
||||
renewed_until=new_end_date,
|
||||
)
|
||||
except Exception as error:
|
||||
await db.rollback()
|
||||
logger.error('Failed to renew tariff subscription', subscription_id=subscription.id, error=error)
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={'code': 'renewal_failed', 'message': 'Failed to renew subscription'},
|
||||
) from error
|
||||
else:
|
||||
# Классический режим
|
||||
try:
|
||||
result = await renewal_service.finalize(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
pricing_model,
|
||||
description=description,
|
||||
)
|
||||
except SubscriptionRenewalChargeError as error:
|
||||
logger.error(
|
||||
'Failed to charge balance for subscription renewal', subscription_id=subscription.id, error=error
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={'code': 'charge_failed', 'message': 'Failed to charge balance'},
|
||||
) from error
|
||||
|
||||
updated_subscription = result.subscription
|
||||
message = _build_renewal_success_message(
|
||||
# Both tariff and classic modes use finalize() for consistent renewal handling
|
||||
# (balance charge, extend, server recording, RemnaWave sync, transaction, admin notification)
|
||||
try:
|
||||
result = await renewal_service.finalize(
|
||||
db,
|
||||
user,
|
||||
updated_subscription,
|
||||
result.total_amount_kopeks,
|
||||
pricing_model.promo_discount_value,
|
||||
subscription,
|
||||
pricing_result,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
except SubscriptionRenewalChargeError as error:
|
||||
logger.error(
|
||||
'Failed to charge balance for subscription renewal', subscription_id=subscription.id, error=error
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={'code': 'charge_failed', 'message': 'Failed to charge balance'},
|
||||
) from error
|
||||
|
||||
return MiniAppSubscriptionRenewalResponse(
|
||||
message=message,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
balance_label=settings.format_price(user.balance_kopeks),
|
||||
subscription_id=updated_subscription.id,
|
||||
renewed_until=updated_subscription.end_date,
|
||||
)
|
||||
updated_subscription = result.subscription
|
||||
message = _build_renewal_success_message(
|
||||
user,
|
||||
updated_subscription,
|
||||
result.total_amount_kopeks,
|
||||
promo_offer_discount_value,
|
||||
)
|
||||
|
||||
return MiniAppSubscriptionRenewalResponse(
|
||||
message=message,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
balance_label=settings.format_price(user.balance_kopeks),
|
||||
subscription_id=updated_subscription.id,
|
||||
renewed_until=updated_subscription.end_date,
|
||||
)
|
||||
|
||||
if not method:
|
||||
if final_total > 0 and balance_kopeks < final_total:
|
||||
@@ -5502,7 +5323,7 @@ async def submit_subscription_renewal_endpoint(
|
||||
period_days,
|
||||
final_total,
|
||||
missing_amount,
|
||||
pricing_snapshot=pricing,
|
||||
pricing_snapshot=dataclasses.asdict(pricing_result),
|
||||
)
|
||||
payload_value = encode_payment_payload(descriptor)
|
||||
|
||||
@@ -6620,6 +6441,15 @@ async def purchase_tariff_endpoint(
|
||||
},
|
||||
)
|
||||
|
||||
# Add extra device cost if user renews same tariff with purchased extra devices
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
if not is_daily_tariff and subscription and subscription.tariff_id == tariff.id:
|
||||
device_price_per_unit = (
|
||||
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
extra_devices = max(0, (subscription.device_limit or 0) - (tariff.device_limit or 0))
|
||||
base_price_kopeks += extra_devices * device_price_per_unit
|
||||
|
||||
# Применяем скидку промогруппы (только для обычных тарифов, не для суточных)
|
||||
price_kopeks = base_price_kopeks
|
||||
discount_percent = 0
|
||||
@@ -6633,7 +6463,18 @@ async def purchase_tariff_endpoint(
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if discount_percent > 0:
|
||||
price_kopeks = int(base_price_kopeks * (100 - discount_percent) / 100)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
price_kopeks = PricingEngine.apply_discount(base_price_kopeks, discount_percent)
|
||||
|
||||
# Apply personal promo_offer discount on top of group discount
|
||||
consume_promo_offer = False
|
||||
if not is_daily_tariff:
|
||||
promo_offer_pct = get_user_active_promo_discount_percent(user)
|
||||
if promo_offer_pct > 0:
|
||||
offer_discount_value = price_kopeks * promo_offer_pct // 100
|
||||
price_kopeks = price_kopeks - offer_discount_value
|
||||
consume_promo_offer = True
|
||||
|
||||
# Проверяем баланс
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
@@ -6647,8 +6488,6 @@ async def purchase_tariff_endpoint(
|
||||
},
|
||||
)
|
||||
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
|
||||
# Списываем баланс
|
||||
if is_daily_tariff:
|
||||
description = f"Активация суточного тарифа '{tariff.name}' (первый день)"
|
||||
@@ -6661,6 +6500,7 @@ async def purchase_tariff_endpoint(
|
||||
user,
|
||||
price_kopeks,
|
||||
description,
|
||||
consume_promo_offer=consume_promo_offer,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
@@ -6692,6 +6532,11 @@ async def purchase_tariff_endpoint(
|
||||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||||
|
||||
if subscription:
|
||||
# Preserve extra purchased devices when renewing the same tariff
|
||||
if subscription.tariff_id == tariff.id:
|
||||
effective_device_limit = max(tariff.device_limit or 0, subscription.device_limit or 0)
|
||||
else:
|
||||
effective_device_limit = tariff.device_limit
|
||||
# Смена/продление тарифа
|
||||
subscription = await extend_subscription(
|
||||
db=db,
|
||||
@@ -6699,7 +6544,7 @@ async def purchase_tariff_endpoint(
|
||||
days=payload.period_days,
|
||||
tariff_id=tariff.id,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
device_limit=effective_device_limit,
|
||||
connected_squads=squads,
|
||||
)
|
||||
else:
|
||||
@@ -6784,11 +6629,10 @@ def _get_user_period_discount(user, period_days: int) -> int:
|
||||
|
||||
|
||||
def _apply_promo_discount(price: int, discount_percent: int) -> int:
|
||||
"""Применяет скидку к цене."""
|
||||
if discount_percent <= 0:
|
||||
return price
|
||||
discount = int(price * discount_percent / 100)
|
||||
return max(0, price - discount)
|
||||
"""Применяет скидку к цене (через PricingEngine для единообразия)."""
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
return PricingEngine.apply_discount(price, discount_percent)
|
||||
|
||||
|
||||
def _calculate_tariff_switch_cost(
|
||||
@@ -6960,6 +6804,16 @@ async def switch_tariff_endpoint(
|
||||
detail={'code': 'no_subscription', 'message': 'No active subscription with tariff'},
|
||||
)
|
||||
|
||||
# Lock subscription row to prevent concurrent switch race condition
|
||||
locked_result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(Subscription.id == subscription.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
subscription = locked_result.scalar_one()
|
||||
user.subscription = subscription
|
||||
|
||||
if subscription.status not in ('active', 'trial'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -7046,6 +6900,7 @@ async def switch_tariff_endpoint(
|
||||
upgrade_cost,
|
||||
description,
|
||||
mark_as_paid_subscription=True,
|
||||
commit=False,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
@@ -7054,22 +6909,26 @@ async def switch_tariff_endpoint(
|
||||
)
|
||||
|
||||
# Записываем транзакцию
|
||||
await create_transaction(
|
||||
switch_transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=upgrade_cost,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
commit=False,
|
||||
)
|
||||
else:
|
||||
# Бесплатный переход (downgrade) — записываем в историю
|
||||
description = f"Переход на тариф '{new_tariff.name}'"
|
||||
switch_transaction = None
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=0,
|
||||
description=description,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Получаем список серверов из тарифа
|
||||
@@ -7083,9 +6942,16 @@ async def switch_tariff_endpoint(
|
||||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||||
|
||||
# Обновляем подписку - меняем тариф без изменения даты
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
subscription.tariff_id = new_tariff.id
|
||||
subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
|
||||
subscription.device_limit = new_tariff.device_limit
|
||||
subscription.device_limit = calc_device_limit_on_tariff_switch(
|
||||
current_device_limit=subscription.device_limit,
|
||||
old_tariff_device_limit=current_tariff.device_limit if current_tariff else None,
|
||||
new_tariff_device_limit=new_tariff.device_limit,
|
||||
max_device_limit=new_tariff.max_device_limit,
|
||||
)
|
||||
subscription.connected_squads = squads
|
||||
# Сбрасываем докупленный трафик при смене тарифа
|
||||
from sqlalchemy import delete as sql_delete
|
||||
@@ -7126,6 +6992,20 @@ async def switch_tariff_endpoint(
|
||||
logger.info('🔄 Смена с суточного на обычный тариф: очищены daily поля')
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
if upgrade_cost > 0 and switch_transaction:
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
switch_transaction,
|
||||
amount_kopeks=upgrade_cost,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -7314,7 +7194,7 @@ async def purchase_traffic_topup_endpoint(
|
||||
# Добавляем трафик (add_subscription_traffic уже создаёт TrafficPurchase и обновляет все необходимые поля)
|
||||
await add_subscription_traffic(db, subscription, payload.gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -7323,6 +7203,9 @@ async def purchase_traffic_topup_endpoint(
|
||||
try:
|
||||
service = SubscriptionService()
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
|
||||
await service.enable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка синхронизации с RemnaWave при докупке трафика', error=e)
|
||||
|
||||
@@ -7387,6 +7270,7 @@ async def toggle_daily_subscription_pause_endpoint(
|
||||
was_disabled = subscription.status in (
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
)
|
||||
|
||||
# System-DISABLED subs (is_daily_paused=False) должны идти по пути resume
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.database.crud.subscription import (
|
||||
remove_subscription_squad,
|
||||
replace_subscription,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import Subscription, SubscriptionStatus
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
@@ -255,13 +256,17 @@ async def add_subscription_traffic_endpoint(
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await add_subscription_traffic(db, subscription, payload.gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
# Синхронизируем с RemnaWave
|
||||
# Синхронизируем с RemnaWave и явно включаем пользователя на панели
|
||||
service = SubscriptionService()
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if user and user.remnawave_uuid and subscription.status == 'active':
|
||||
await service.enable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
@@ -276,13 +281,17 @@ async def add_subscription_devices_endpoint(
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await add_subscription_devices(db, subscription, payload.devices)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
# Синхронизируем с RemnaWave
|
||||
# Синхронизируем с RemnaWave и явно включаем пользователя на панели
|
||||
service = SubscriptionService()
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if user and user.remnawave_uuid and subscription.status == 'active':
|
||||
await service.enable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@@ -705,8 +705,10 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'})
|
||||
|
||||
transaction_id = payload.get('transactionId', 'unknown')
|
||||
logger.error('Platega webhook processing failed: transactionId', transaction_id=transaction_id)
|
||||
transaction_id = (
|
||||
payload.get('id') or payload.get('transactionId') or payload.get('transaction_id') or 'unknown'
|
||||
)
|
||||
logger.error('Platega webhook processing failed', transaction_id=transaction_id)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'not_processed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add show_in_gift to tariffs
|
||||
|
||||
Boolean flag to control tariff visibility in the gift section.
|
||||
Defaults to True so all existing tariffs remain visible.
|
||||
|
||||
Revision ID: 0038
|
||||
Revises: 0037
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0038'
|
||||
down_revision: Union[str, None] = '0037'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _has_column(table: str, column: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
return column in [c['name'] for c in inspector.get_columns(table)]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_column('tariffs', 'show_in_gift'):
|
||||
op.add_column(
|
||||
'tariffs',
|
||||
sa.Column('show_in_gift', sa.Boolean(), nullable=False, server_default=sa.text('true')),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('tariffs', 'show_in_gift')
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.30.0"
|
||||
version = "3.32.1"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
|
||||
@@ -20,6 +20,7 @@ os.environ.setdefault('BOT_TOKEN', 'test-token')
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod
|
||||
from app.services.payment.cryptobot import CryptoBotPaymentMixin
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
from app.services.subscription_renewal_service import (
|
||||
SubscriptionRenewalPricing,
|
||||
SubscriptionRenewalResult,
|
||||
@@ -348,7 +349,9 @@ async def test_cryptobot_renewal_uses_pricing_snapshot(monkeypatch):
|
||||
module = sys.modules['app.services.payment.cryptobot']
|
||||
mixin = CryptoBotPaymentMixin()
|
||||
|
||||
subscription = types.SimpleNamespace(id=77, connected_squads=[], traffic_limit_gb=100, device_limit=5)
|
||||
subscription = types.SimpleNamespace(
|
||||
id=77, connected_squads=[], traffic_limit_gb=100, device_limit=5, tariff=None, tariff_id=None
|
||||
)
|
||||
user = types.SimpleNamespace(id=5, balance_kopeks=7000, subscription=subscription)
|
||||
|
||||
pricing_model = SubscriptionRenewalPricing(
|
||||
@@ -385,9 +388,9 @@ async def test_cryptobot_renewal_uses_pricing_snapshot(monkeypatch):
|
||||
)
|
||||
|
||||
async def fail_calculate(*args, **kwargs):
|
||||
raise AssertionError('calculate_pricing should not be called when snapshot is present')
|
||||
raise AssertionError('PricingEngine.calculate_renewal_price should not be called when snapshot is present')
|
||||
|
||||
monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fail_calculate)
|
||||
monkeypatch.setattr(PricingEngine, 'calculate_renewal_price', fail_calculate)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
@@ -431,7 +434,9 @@ async def test_cryptobot_renewal_accepts_changed_pricing_without_snapshot(monkey
|
||||
module = sys.modules['app.services.payment.cryptobot']
|
||||
mixin = CryptoBotPaymentMixin()
|
||||
|
||||
subscription = types.SimpleNamespace(id=55, connected_squads=[], traffic_limit_gb=50, device_limit=3)
|
||||
subscription = types.SimpleNamespace(
|
||||
id=55, connected_squads=[], traffic_limit_gb=50, device_limit=3, tariff=None, tariff_id=None
|
||||
)
|
||||
user = types.SimpleNamespace(id=8, balance_kopeks=4000, subscription=subscription)
|
||||
|
||||
descriptor = build_payment_descriptor(
|
||||
@@ -451,25 +456,26 @@ async def test_cryptobot_renewal_accepts_changed_pricing_without_snapshot(monkey
|
||||
sys.modules, 'app.services.payment_service', types.SimpleNamespace(get_user_by_id=fake_get_user_by_id)
|
||||
)
|
||||
|
||||
# Recalculated price is LOWER than descriptor — user benefits, should proceed
|
||||
recalculated_pricing = SubscriptionRenewalPricing(
|
||||
period_days=30,
|
||||
period_id='days:30',
|
||||
months=1,
|
||||
base_original_total=5200,
|
||||
discounted_total=5200,
|
||||
final_total=5200,
|
||||
base_original_total=4800,
|
||||
discounted_total=4800,
|
||||
final_total=4800,
|
||||
promo_discount_value=0,
|
||||
promo_discount_percent=0,
|
||||
overall_discount_percent=0,
|
||||
per_month=5200,
|
||||
per_month=4800,
|
||||
server_ids=[],
|
||||
details={},
|
||||
)
|
||||
|
||||
async def fake_calculate(db, u, sub, period):
|
||||
async def fake_calculate(self, db, sub, period_days, *, user=None):
|
||||
return recalculated_pricing
|
||||
|
||||
monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fake_calculate)
|
||||
monkeypatch.setattr(PricingEngine, 'calculate_renewal_price', fake_calculate)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
@@ -499,8 +505,10 @@ async def test_cryptobot_renewal_accepts_changed_pricing_without_snapshot(monkey
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert captured['pricing'].final_total == 5000
|
||||
assert captured['charge'] == 4000
|
||||
# With C-4 fix: recalculated price (4800) < descriptor (5000) → use recalculated
|
||||
assert captured['pricing'].final_total == 4800
|
||||
# M-5 fix: required_balance = max(0, final_total - missing) = max(0, 4800 - 1000) = 3800
|
||||
assert captured['charge'] == 3800
|
||||
|
||||
|
||||
@pytest.mark.anyio('asyncio')
|
||||
@@ -508,7 +516,9 @@ async def test_cryptobot_webhook_uses_inline_payload_when_db_missing(monkeypatch
|
||||
module = sys.modules['app.services.payment.cryptobot']
|
||||
mixin = CryptoBotPaymentMixin()
|
||||
|
||||
subscription = types.SimpleNamespace(id=91, connected_squads=[], traffic_limit_gb=80, device_limit=4)
|
||||
subscription = types.SimpleNamespace(
|
||||
id=91, connected_squads=[], traffic_limit_gb=80, device_limit=4, tariff=None, tariff_id=None
|
||||
)
|
||||
user = types.SimpleNamespace(id=21, balance_kopeks=6000, subscription=subscription)
|
||||
|
||||
pricing_model = SubscriptionRenewalPricing(
|
||||
@@ -560,9 +570,9 @@ async def test_cryptobot_webhook_uses_inline_payload_when_db_missing(monkeypatch
|
||||
)
|
||||
|
||||
async def fail_calculate(*args, **kwargs):
|
||||
raise AssertionError('calculate_pricing should not be called')
|
||||
raise AssertionError('PricingEngine.calculate_renewal_price should not be called')
|
||||
|
||||
monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fail_calculate)
|
||||
monkeypatch.setattr(PricingEngine, 'calculate_renewal_price', fail_calculate)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
|
||||
@@ -0,0 +1,970 @@
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.pricing_engine import PricingEngine, RenewalPricing
|
||||
|
||||
|
||||
def test_renewal_pricing_is_frozen():
|
||||
p = RenewalPricing(
|
||||
base_price=29000,
|
||||
servers_price=5000,
|
||||
traffic_price=0,
|
||||
devices_price=0,
|
||||
promo_group_discount=0,
|
||||
promo_offer_discount=0,
|
||||
final_total=34000,
|
||||
period_days=30,
|
||||
is_tariff_mode=False,
|
||||
)
|
||||
assert p.final_total == 34000
|
||||
with pytest.raises(AttributeError):
|
||||
p.final_total = 0
|
||||
|
||||
|
||||
class TestApplyDiscount:
|
||||
def test_basic_discount(self):
|
||||
assert PricingEngine.apply_discount(10000, 20) == 8000
|
||||
|
||||
def test_zero_discount(self):
|
||||
assert PricingEngine.apply_discount(10000, 0) == 10000
|
||||
|
||||
def test_full_discount(self):
|
||||
assert PricingEngine.apply_discount(10000, 100) == 0
|
||||
|
||||
def test_negative_clamped(self):
|
||||
assert PricingEngine.apply_discount(10000, -5) == 10000
|
||||
|
||||
def test_over_100_clamped(self):
|
||||
assert PricingEngine.apply_discount(10000, 150) == 0
|
||||
|
||||
def test_integer_floor_division(self):
|
||||
assert PricingEngine.apply_discount(99900, 30) == 69930
|
||||
|
||||
|
||||
class TestStackedDiscounts:
|
||||
def test_group_then_offer(self):
|
||||
final, g_val, o_val = PricingEngine.apply_stacked_discounts(10000, 20, 10)
|
||||
assert final == 7200
|
||||
assert g_val == 2000
|
||||
assert o_val == 800
|
||||
|
||||
def test_no_discounts(self):
|
||||
final, g_val, o_val = PricingEngine.apply_stacked_discounts(10000, 0, 0)
|
||||
assert final == 10000
|
||||
assert g_val == 0
|
||||
assert o_val == 0
|
||||
|
||||
def test_only_offer(self):
|
||||
final, g_val, o_val = PricingEngine.apply_stacked_discounts(10000, 0, 15)
|
||||
assert final == 8500
|
||||
assert g_val == 0
|
||||
assert o_val == 1500
|
||||
|
||||
def test_only_group(self):
|
||||
result, gd, od = PricingEngine.apply_stacked_discounts(10000, 20, 0)
|
||||
assert result == 8000
|
||||
assert gd == 2000
|
||||
assert od == 0
|
||||
|
||||
def test_both_100_percent(self):
|
||||
result, gd, od = PricingEngine.apply_stacked_discounts(10000, 100, 100)
|
||||
assert result == 0
|
||||
assert gd == 10000
|
||||
assert od == 0 # offer discount on 0 is 0
|
||||
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
class TestPeriodDaysValidation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_period_days_raises(self):
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = None
|
||||
subscription.tariff = None
|
||||
with pytest.raises(ValueError, match='Invalid period_days'):
|
||||
await engine.calculate_renewal_price(db, subscription, -1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_period_days_raises(self):
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = None
|
||||
subscription.tariff = None
|
||||
with pytest.raises(ValueError, match='Invalid period_days'):
|
||||
await engine.calculate_renewal_price(db, subscription, 0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_float_period_days_raises(self):
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = None
|
||||
subscription.tariff = None
|
||||
with pytest.raises(ValueError, match='Invalid period_days'):
|
||||
await engine.calculate_renewal_price(db, subscription, 30.0)
|
||||
|
||||
|
||||
_server_id_seq = itertools.count(1)
|
||||
|
||||
|
||||
def _make_server(
|
||||
price_kopeks=5000, is_available=True, is_full=False, allowed_promo_groups=None, server_id=None, squad_uuid=None
|
||||
):
|
||||
if server_id is None:
|
||||
server_id = next(_server_id_seq)
|
||||
server = MagicMock()
|
||||
server.id = server_id
|
||||
server.squad_uuid = squad_uuid
|
||||
server.price_kopeks = price_kopeks
|
||||
server.is_available = is_available
|
||||
server.is_full = is_full
|
||||
server.allowed_promo_groups = allowed_promo_groups or []
|
||||
return server
|
||||
|
||||
|
||||
class TestCalculateServersPrice:
|
||||
@pytest.mark.asyncio
|
||||
async def test_available_server(self):
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
server = _make_server(price_kopeks=5000, squad_uuid='uuid-1')
|
||||
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]):
|
||||
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None)
|
||||
assert total == 5000
|
||||
assert len(details) == 1
|
||||
assert details[0]['price'] == 5000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unavailable_server_uses_real_price(self):
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
server = _make_server(price_kopeks=7000, is_available=False, squad_uuid='uuid-1')
|
||||
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]):
|
||||
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None)
|
||||
assert total == 7000 # NOT 0!
|
||||
assert details[0]['status'] == 'unavailable'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_server_uses_real_price(self):
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
server = _make_server(price_kopeks=3000, is_full=True, squad_uuid='uuid-1')
|
||||
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]):
|
||||
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None)
|
||||
assert total == 3000 # NOT 0!
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_not_found_zero_price(self):
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[]):
|
||||
total, details = await engine._calculate_servers_price(['uuid-orphan'], db, promo_group_id=None)
|
||||
assert total == 0
|
||||
assert details[0]['status'] == 'not_found'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_servers(self):
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
s1 = _make_server(price_kopeks=5000, squad_uuid='uuid-1')
|
||||
s2 = _make_server(price_kopeks=3000, is_available=False, squad_uuid='uuid-2')
|
||||
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[s1, s2]):
|
||||
total, details = await engine._calculate_servers_price(['uuid-1', 'uuid-2'], db, promo_group_id=None)
|
||||
assert total == 8000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_ids_and_prices_alignment_with_not_found(self):
|
||||
"""Verify server_ids and servers_individual_prices have same length when some servers are not found."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
s1 = _make_server(price_kopeks=5000, server_id=10, squad_uuid='uuid-1')
|
||||
s3 = _make_server(price_kopeks=3000, server_id=30, squad_uuid='uuid-3')
|
||||
# uuid-orphan not in batch result — should be excluded from BOTH lists
|
||||
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[s1, s3]):
|
||||
total, details = await engine._calculate_servers_price(
|
||||
['uuid-1', 'uuid-orphan', 'uuid-3'], db, promo_group_id=None
|
||||
)
|
||||
assert total == 8000 # 5000 + 0 + 3000
|
||||
assert len(details) == 3
|
||||
# Verify id fields
|
||||
assert details[0]['id'] == 10
|
||||
assert details[1]['id'] is None
|
||||
assert details[2]['id'] == 30
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_exception_path(self):
|
||||
"""Verify batch DB exception returns price=0 and status=error for all UUIDs."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
with patch('app.services.pricing_engine.get_server_squads_by_uuids', side_effect=RuntimeError('DB error')):
|
||||
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None)
|
||||
assert total == 0
|
||||
assert details[0]['status'] == 'error'
|
||||
assert details[0]['id'] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_uuids_returns_empty(self):
|
||||
"""Verify empty UUIDs list returns 0 total and empty details."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
total, details = await engine._calculate_servers_price([], db, promo_group_id=None)
|
||||
assert total == 0
|
||||
assert details == []
|
||||
|
||||
|
||||
class TestCalculateTrafficPrice:
|
||||
def test_base_only(self):
|
||||
engine = PricingEngine()
|
||||
with patch('app.services.pricing_engine.settings') as ms:
|
||||
ms.get_traffic_price.side_effect = lambda gb: {25: 3000, 50: 5000}.get(gb, 0)
|
||||
price = engine._calculate_traffic_price(traffic_limit_gb=25, purchased_traffic_gb=0)
|
||||
assert price == 3000
|
||||
|
||||
def test_purchased_separated(self):
|
||||
engine = PricingEngine()
|
||||
with patch('app.services.pricing_engine.settings') as ms:
|
||||
ms.get_traffic_price.side_effect = lambda gb: {25: 3000, 100: 8000, 125: 12000}.get(gb, 0)
|
||||
price = engine._calculate_traffic_price(traffic_limit_gb=125, purchased_traffic_gb=100)
|
||||
assert price == 11000 # NOT 12000
|
||||
|
||||
def test_zero_traffic(self):
|
||||
engine = PricingEngine()
|
||||
with patch('app.services.pricing_engine.settings') as ms:
|
||||
ms.get_traffic_price.return_value = 0
|
||||
price = engine._calculate_traffic_price(traffic_limit_gb=0, purchased_traffic_gb=0)
|
||||
assert price == 0
|
||||
|
||||
def test_purchased_exceeds_total(self):
|
||||
engine = PricingEngine()
|
||||
with patch('app.services.pricing_engine.settings') as ms:
|
||||
ms.get_traffic_price.side_effect = lambda gb: {0: 0, 100: 8000}.get(gb, 0)
|
||||
price = engine._calculate_traffic_price(traffic_limit_gb=80, purchased_traffic_gb=100)
|
||||
assert price == 8000 # base_gb clamped to 0
|
||||
|
||||
|
||||
class TestCalculateRenewalPriceTariffMode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_tariff_basic(self):
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = 2
|
||||
subscription.tariff = MagicMock()
|
||||
subscription.tariff.period_prices = {'30': 19000}
|
||||
subscription.tariff.device_limit = 2
|
||||
subscription.tariff.device_price_kopeks = None
|
||||
subscription.tariff.id = 2
|
||||
subscription.device_limit = 2
|
||||
subscription.connected_squads = []
|
||||
subscription.traffic_limit_gb = 50
|
||||
subscription.purchased_traffic_gb = 0
|
||||
user = MagicMock()
|
||||
user.promo_group = None
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_expires_at = None
|
||||
with (
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
|
||||
patch('app.services.pricing_engine.settings') as ms,
|
||||
):
|
||||
ms.PRICE_PER_DEVICE = 5000
|
||||
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
|
||||
assert result.is_tariff_mode is True
|
||||
assert result.final_total == 19000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tariff_extra_devices(self):
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = 2
|
||||
subscription.tariff = MagicMock()
|
||||
subscription.tariff.period_prices = {'30': 19000}
|
||||
subscription.tariff.device_limit = 2
|
||||
subscription.tariff.device_price_kopeks = None
|
||||
subscription.tariff.id = 2
|
||||
subscription.device_limit = 4
|
||||
subscription.connected_squads = []
|
||||
subscription.traffic_limit_gb = 50
|
||||
subscription.purchased_traffic_gb = 0
|
||||
user = MagicMock()
|
||||
user.promo_group = None
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_expires_at = None
|
||||
with (
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
|
||||
patch('app.services.pricing_engine.settings') as ms,
|
||||
):
|
||||
ms.PRICE_PER_DEVICE = 5000
|
||||
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
|
||||
assert result.devices_price == 10000
|
||||
assert result.final_total == 29000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tariff_device_price_from_tariff(self):
|
||||
"""When tariff has device_price_kopeks set, use it instead of settings."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = 2
|
||||
subscription.tariff = MagicMock()
|
||||
subscription.tariff.period_prices = {'30': 10000}
|
||||
subscription.tariff.device_limit = 2
|
||||
subscription.tariff.device_price_kopeks = 3000 # tariff-specific price
|
||||
subscription.tariff.id = 2
|
||||
subscription.device_limit = 4 # 2 extra devices
|
||||
user = MagicMock()
|
||||
user.promo_group = None
|
||||
with (
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
|
||||
patch('app.services.pricing_engine.settings') as ms,
|
||||
):
|
||||
ms.PRICE_PER_DEVICE = 5000 # should NOT be used
|
||||
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
|
||||
assert result.devices_price == 6000 # 2 extra × 3000 (tariff price)
|
||||
assert result.final_total == 16000 # 10000 + 6000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tariff_with_discounts(self):
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = 1
|
||||
subscription.tariff = MagicMock()
|
||||
subscription.tariff.period_prices = {'30': 20000}
|
||||
subscription.tariff.device_limit = 1
|
||||
subscription.tariff.device_price_kopeks = None
|
||||
subscription.tariff.id = 1
|
||||
subscription.device_limit = 1
|
||||
promo_group = MagicMock()
|
||||
promo_group.get_discount_percent.return_value = 10
|
||||
user = MagicMock()
|
||||
user.promo_group = promo_group
|
||||
with (
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=5),
|
||||
patch('app.services.pricing_engine.settings') as ms,
|
||||
):
|
||||
ms.PRICE_PER_DEVICE = 5000
|
||||
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
|
||||
assert result.base_price == 20000
|
||||
assert result.promo_group_discount == 2000
|
||||
# After group: 18000, then 5% off 18000 = 900
|
||||
assert result.promo_offer_discount == 900
|
||||
assert result.final_total == 17100
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tariff_missing_period_returns_zero_base(self):
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = 1
|
||||
subscription.tariff = MagicMock()
|
||||
subscription.tariff.period_prices = {'30': 19000}
|
||||
subscription.tariff.device_limit = 1
|
||||
subscription.tariff.device_price_kopeks = None
|
||||
subscription.tariff.id = 1
|
||||
subscription.device_limit = 1
|
||||
user = MagicMock()
|
||||
user.promo_group = None
|
||||
with (
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
|
||||
patch('app.services.pricing_engine.settings') as ms,
|
||||
):
|
||||
ms.PRICE_PER_DEVICE = 5000
|
||||
result = await engine.calculate_renewal_price(db, subscription, 60, user=user)
|
||||
assert result.base_price == 0
|
||||
assert result.final_total == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tariff_device_limit_below_tariff_included(self):
|
||||
"""When subscription device_limit < tariff device_limit, extra_devices is 0 (not negative)."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
tariff = MagicMock()
|
||||
tariff.id = 1
|
||||
tariff.period_prices = {'30': 10000}
|
||||
tariff.device_price_kopeks = 5000
|
||||
tariff.device_limit = 5
|
||||
sub = MagicMock()
|
||||
sub.tariff_id = 1
|
||||
sub.tariff = tariff
|
||||
sub.device_limit = 2 # less than tariff's 5
|
||||
user = MagicMock()
|
||||
user.promo_group = None
|
||||
|
||||
with patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0):
|
||||
result = await engine.calculate_renewal_price(db, sub, 30, user=user)
|
||||
|
||||
assert result.devices_price == 0
|
||||
assert result.final_total == 10000
|
||||
assert result.breakdown.get('extra_devices') == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tariff_user_none(self):
|
||||
"""When user=None, no discounts are applied."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = 1
|
||||
subscription.tariff = MagicMock()
|
||||
subscription.tariff.period_prices = {'30': 20000}
|
||||
subscription.tariff.device_limit = 1
|
||||
subscription.tariff.device_price_kopeks = None
|
||||
subscription.tariff.id = 1
|
||||
subscription.device_limit = 1
|
||||
with (
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
|
||||
patch('app.services.pricing_engine.settings') as ms,
|
||||
):
|
||||
ms.PRICE_PER_DEVICE = 5000
|
||||
result = await engine.calculate_renewal_price(db, subscription, 30, user=None)
|
||||
assert result.final_total == 20000
|
||||
assert result.promo_group_discount == 0
|
||||
assert result.promo_offer_discount == 0
|
||||
|
||||
|
||||
class TestCalculateRenewalPriceClassicMode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_classic_all_components(self):
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = None
|
||||
subscription.tariff = None
|
||||
subscription.connected_squads = ['uuid-1']
|
||||
subscription.traffic_limit_gb = 50
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.device_limit = 2
|
||||
user = MagicMock()
|
||||
user.promo_group = None
|
||||
user.promo_group_id = None
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_expires_at = None
|
||||
server = _make_server(price_kopeks=5000, squad_uuid='uuid-1')
|
||||
with (
|
||||
patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]),
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
|
||||
patch('app.services.pricing_engine.settings') as ms,
|
||||
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 29000}),
|
||||
patch('app.services.pricing_engine.PERIOD_PRICES', {30: 29000}),
|
||||
):
|
||||
ms.get_traffic_price.return_value = 3000
|
||||
ms.PRICE_PER_DEVICE = 0
|
||||
ms.DEFAULT_DEVICE_LIMIT = 2
|
||||
ms.is_traffic_fixed.return_value = False
|
||||
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
|
||||
assert result.is_tariff_mode is False
|
||||
assert result.base_price == 29000
|
||||
assert result.servers_price == 5000
|
||||
assert result.traffic_price == 3000
|
||||
assert result.final_total == 37000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classic_with_discounts(self):
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = None
|
||||
subscription.tariff = None
|
||||
subscription.connected_squads = []
|
||||
subscription.traffic_limit_gb = 0
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.device_limit = 2
|
||||
promo_group = MagicMock()
|
||||
promo_group.id = 1
|
||||
promo_group.get_discount_percent.return_value = 20
|
||||
user = MagicMock()
|
||||
user.promo_group = promo_group
|
||||
user.promo_group_id = 1
|
||||
user.promo_offer_discount_percent = 10
|
||||
user.promo_offer_expires_at = None
|
||||
with (
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=10),
|
||||
patch('app.services.pricing_engine.settings') as ms,
|
||||
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
|
||||
patch('app.services.pricing_engine.PERIOD_PRICES', {30: 10000}),
|
||||
):
|
||||
ms.get_traffic_price.return_value = 0
|
||||
ms.PRICE_PER_DEVICE = 0
|
||||
ms.DEFAULT_DEVICE_LIMIT = 2
|
||||
ms.is_traffic_fixed.return_value = False
|
||||
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
|
||||
assert result.final_total == 7200
|
||||
assert result.promo_group_discount == 2000
|
||||
assert result.promo_offer_discount == 800
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classic_fallback_to_period_prices(self):
|
||||
"""When CLASSIC_PERIOD_PRICES has no entry, falls back to PERIOD_PRICES."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = None
|
||||
subscription.tariff = None
|
||||
subscription.connected_squads = []
|
||||
subscription.traffic_limit_gb = 0
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.device_limit = 1
|
||||
user = MagicMock()
|
||||
user.promo_group = None
|
||||
user.promo_group_id = None
|
||||
with (
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
|
||||
patch('app.services.pricing_engine.settings') as ms,
|
||||
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {}),
|
||||
patch('app.services.pricing_engine.PERIOD_PRICES', {30: 99000}),
|
||||
):
|
||||
ms.get_traffic_price.return_value = 0
|
||||
ms.PRICE_PER_DEVICE = 0
|
||||
ms.DEFAULT_DEVICE_LIMIT = 1
|
||||
ms.is_traffic_fixed.return_value = False
|
||||
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
|
||||
assert result.base_price == 99000
|
||||
assert result.final_total == 99000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classic_extra_devices(self):
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = None
|
||||
subscription.tariff = None
|
||||
subscription.connected_squads = []
|
||||
subscription.traffic_limit_gb = 0
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.device_limit = 5
|
||||
user = MagicMock()
|
||||
user.promo_group = None
|
||||
user.promo_group_id = None
|
||||
with (
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
|
||||
patch('app.services.pricing_engine.settings') as ms,
|
||||
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
|
||||
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
|
||||
):
|
||||
ms.get_traffic_price.return_value = 0
|
||||
ms.PRICE_PER_DEVICE = 3000
|
||||
ms.DEFAULT_DEVICE_LIMIT = 2
|
||||
ms.is_traffic_fixed.return_value = False
|
||||
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
|
||||
# 5 - 2 = 3 extra devices * 3000 = 9000
|
||||
assert result.devices_price == 9000
|
||||
assert result.final_total == 19000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classic_breakdown_server_ids_and_prices_alignment(self):
|
||||
"""Verify server_ids and servers_individual_prices have same length when orphaned UUIDs present."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = None
|
||||
subscription.tariff = None
|
||||
subscription.connected_squads = ['uuid-found', 'uuid-orphan', 'uuid-found2']
|
||||
subscription.traffic_limit_gb = 0
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.device_limit = 1
|
||||
user = MagicMock()
|
||||
user.promo_group = None
|
||||
user.promo_group_id = None
|
||||
s1 = _make_server(price_kopeks=5000, server_id=10, squad_uuid='uuid-found')
|
||||
s3 = _make_server(price_kopeks=3000, server_id=30, squad_uuid='uuid-found2')
|
||||
with (
|
||||
patch(
|
||||
'app.services.pricing_engine.get_server_squads_by_uuids',
|
||||
return_value=[s1, s3],
|
||||
),
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
|
||||
patch('app.services.pricing_engine.settings') as ms,
|
||||
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
|
||||
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
|
||||
):
|
||||
ms.get_traffic_price.return_value = 0
|
||||
ms.PRICE_PER_DEVICE = 0
|
||||
ms.DEFAULT_DEVICE_LIMIT = 1
|
||||
ms.is_traffic_fixed.return_value = False
|
||||
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
|
||||
# Verify breakdown alignment — both lists must have same length
|
||||
ids = result.breakdown['server_ids']
|
||||
prices = result.breakdown['servers_individual_prices']
|
||||
assert len(ids) == len(prices), f'server_ids({len(ids)}) != prices({len(prices)})'
|
||||
assert ids == [10, 30]
|
||||
assert prices == [5000, 3000]
|
||||
# Total servers_price includes only found servers
|
||||
assert result.servers_price == 8000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classic_fixed_traffic_ignores_subscription_values(self):
|
||||
"""When is_traffic_fixed() is True, use fixed limit and zero purchased."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = None
|
||||
subscription.tariff = None
|
||||
subscription.connected_squads = []
|
||||
subscription.traffic_limit_gb = 999 # should be ignored
|
||||
subscription.purchased_traffic_gb = 500 # should be ignored
|
||||
subscription.device_limit = 1
|
||||
user = MagicMock()
|
||||
user.promo_group = None
|
||||
user.promo_group_id = None
|
||||
with (
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
|
||||
patch('app.services.pricing_engine.settings') as ms,
|
||||
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
|
||||
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
|
||||
):
|
||||
ms.is_traffic_fixed.return_value = True
|
||||
ms.get_fixed_traffic_limit.return_value = 50
|
||||
ms.get_traffic_price.side_effect = lambda gb: {50: 4000}.get(gb, 0)
|
||||
ms.PRICE_PER_DEVICE = 0
|
||||
ms.DEFAULT_DEVICE_LIMIT = 1
|
||||
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
|
||||
assert result.traffic_price == 4000
|
||||
assert result.breakdown['purchased_traffic_gb'] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classic_default_traffic_limit_when_none(self):
|
||||
"""When subscription.traffic_limit_gb is None, use DEFAULT_TRAFFIC_LIMIT_GB."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = None
|
||||
subscription.tariff = None
|
||||
subscription.connected_squads = []
|
||||
subscription.traffic_limit_gb = None # should fallback to default
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.device_limit = 1
|
||||
user = MagicMock()
|
||||
user.promo_group = None
|
||||
user.promo_group_id = None
|
||||
with (
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
|
||||
patch('app.services.pricing_engine.settings') as ms,
|
||||
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
|
||||
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
|
||||
):
|
||||
ms.is_traffic_fixed.return_value = False
|
||||
ms.DEFAULT_TRAFFIC_LIMIT_GB = 50
|
||||
ms.get_traffic_price.side_effect = lambda gb: {50: 4000}.get(gb, 0)
|
||||
ms.PRICE_PER_DEVICE = 0
|
||||
ms.DEFAULT_DEVICE_LIMIT = 1
|
||||
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
|
||||
assert result.traffic_price == 4000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classic_multi_month_period(self):
|
||||
"""90-day period multiplies monthly prices by 3."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
sub = MagicMock()
|
||||
sub.tariff_id = None
|
||||
sub.tariff = None
|
||||
sub.connected_squads = ['uuid-s1']
|
||||
sub.traffic_limit_gb = 50
|
||||
sub.purchased_traffic_gb = 0
|
||||
sub.device_limit = 1
|
||||
user = MagicMock()
|
||||
user.promo_group = None
|
||||
user.promo_group_id = None
|
||||
|
||||
server = _make_server(price_kopeks=3000, squad_uuid='uuid-s1')
|
||||
|
||||
with (
|
||||
patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]),
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
|
||||
patch('app.services.pricing_engine.settings') as ms,
|
||||
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {90: 27000}),
|
||||
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
|
||||
):
|
||||
ms.DEFAULT_DEVICE_LIMIT = 1
|
||||
ms.PRICE_PER_DEVICE = 5000
|
||||
ms.get_traffic_price.return_value = 2000
|
||||
ms.is_traffic_fixed.return_value = False
|
||||
ms.DEFAULT_TRAFFIC_LIMIT_GB = 50
|
||||
|
||||
result = await engine.calculate_renewal_price(db, sub, 90, user=user)
|
||||
|
||||
assert result.period_days == 90
|
||||
assert result.base_price == 27000
|
||||
# Servers and traffic are monthly x 3 months
|
||||
assert result.servers_price == 3000 * 3
|
||||
assert result.traffic_price == 2000 * 3
|
||||
assert result.devices_price == 0 # no extra devices
|
||||
assert result.final_total == 27000 + 9000 + 6000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classic_per_category_different_discounts(self):
|
||||
"""Different discount percents per category (period=10%, servers=20%, traffic=30%, devices=0%)."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
sub = MagicMock()
|
||||
sub.tariff_id = None
|
||||
sub.tariff = None
|
||||
sub.connected_squads = ['uuid-s1']
|
||||
sub.traffic_limit_gb = 100
|
||||
sub.purchased_traffic_gb = 0
|
||||
sub.device_limit = 3 # 2 extra devices
|
||||
|
||||
user = MagicMock()
|
||||
promo_group = MagicMock()
|
||||
|
||||
def discount_by_category(category, period_days):
|
||||
return {'period': 10, 'servers': 20, 'traffic': 30, 'devices': 0}[category]
|
||||
|
||||
promo_group.get_discount_percent = MagicMock(side_effect=discount_by_category)
|
||||
user.promo_group = promo_group
|
||||
user.promo_group_id = 1
|
||||
|
||||
server = _make_server(price_kopeks=6000, squad_uuid='uuid-s1')
|
||||
|
||||
with (
|
||||
patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]),
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
|
||||
patch('app.services.pricing_engine.settings') as ms,
|
||||
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
|
||||
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
|
||||
):
|
||||
ms.DEFAULT_DEVICE_LIMIT = 1
|
||||
ms.PRICE_PER_DEVICE = 4000
|
||||
ms.get_traffic_price.return_value = 5000
|
||||
ms.is_traffic_fixed.return_value = False
|
||||
ms.DEFAULT_TRAFFIC_LIMIT_GB = 100
|
||||
|
||||
result = await engine.calculate_renewal_price(db, sub, 30, user=user)
|
||||
|
||||
# period: 10000 * 10% = 1000 discount -> 9000
|
||||
assert result.base_price == 9000
|
||||
# servers: 6000 * 20% = 1200 discount -> 4800 per month x 1
|
||||
assert result.servers_price == 4800
|
||||
# traffic: 5000 * 30% = 1500 discount -> 3500 per month x 1
|
||||
assert result.traffic_price == 3500
|
||||
# devices: 2 extra x 4000 = 8000, 0% discount -> 8000
|
||||
assert result.devices_price == 8000
|
||||
# total group discount = 1000 + 1200 + 1500 + 0 = 3700
|
||||
assert result.promo_group_discount == 3700
|
||||
assert result.final_total == 9000 + 4800 + 3500 + 8000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classic_user_none(self):
|
||||
"""When user=None, no discounts are applied."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
subscription = MagicMock()
|
||||
subscription.tariff_id = None
|
||||
subscription.tariff = None
|
||||
subscription.connected_squads = []
|
||||
subscription.traffic_limit_gb = 0
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.device_limit = 1
|
||||
with (
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
|
||||
patch('app.services.pricing_engine.settings') as ms,
|
||||
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 15000}),
|
||||
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
|
||||
):
|
||||
ms.get_traffic_price.return_value = 0
|
||||
ms.PRICE_PER_DEVICE = 0
|
||||
ms.DEFAULT_DEVICE_LIMIT = 1
|
||||
ms.is_traffic_fixed.return_value = False
|
||||
result = await engine.calculate_renewal_price(db, subscription, 30, user=None)
|
||||
assert result.final_total == 15000
|
||||
assert result.promo_group_discount == 0
|
||||
assert result.promo_offer_discount == 0
|
||||
|
||||
|
||||
class TestServerPromoGroupFiltering:
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_not_allowed_for_promo_group(self):
|
||||
"""Server with restricted promo groups still charges real price."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
pg_mock = MagicMock()
|
||||
pg_mock.id = 99
|
||||
server = _make_server(price_kopeks=5000, squad_uuid='uuid-1', allowed_promo_groups=[pg_mock])
|
||||
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]):
|
||||
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=5)
|
||||
assert total == 5000 # real price still charged
|
||||
assert details[0]['status'] == 'not_allowed'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_empty_allowed_groups_is_open(self):
|
||||
"""Server with empty allowed_promo_groups is available to all."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
server = _make_server(price_kopeks=5000, squad_uuid='uuid-1', allowed_promo_groups=[])
|
||||
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]):
|
||||
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=5)
|
||||
assert total == 5000
|
||||
assert details[0]['status'] == 'available'
|
||||
|
||||
|
||||
class TestFromPayloadRoundTrip:
|
||||
def test_renewal_pricing_snapshot_roundtrip(self):
|
||||
"""RenewalPricing serialized via asdict() is correctly restored by from_payload()."""
|
||||
import dataclasses
|
||||
|
||||
from app.services.subscription_renewal_service import SubscriptionRenewalPricing
|
||||
|
||||
pricing = RenewalPricing(
|
||||
base_price=29000,
|
||||
servers_price=5000,
|
||||
traffic_price=3000,
|
||||
devices_price=0,
|
||||
promo_group_discount=2000,
|
||||
promo_offer_discount=800,
|
||||
final_total=34200,
|
||||
period_days=30,
|
||||
is_tariff_mode=False,
|
||||
breakdown={
|
||||
'server_ids': [1, 2],
|
||||
'servers_individual_prices': [5000, 3000],
|
||||
'offer_discount_pct': 5,
|
||||
},
|
||||
)
|
||||
payload = dataclasses.asdict(pricing)
|
||||
restored = SubscriptionRenewalPricing.from_payload(payload)
|
||||
|
||||
assert restored.final_total == 34200
|
||||
assert restored.period_days == 30
|
||||
assert restored.promo_discount_value == 800 # mapped from promo_offer_discount
|
||||
assert restored.server_ids == [1, 2]
|
||||
assert restored.details.get('servers_individual_prices') == [5000, 3000]
|
||||
assert restored.months == 1
|
||||
assert restored.per_month == 34200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch-target constants used in new tests below
|
||||
# ---------------------------------------------------------------------------
|
||||
SERVERS_BATCH_PATH = 'app.services.pricing_engine.get_server_squads_by_uuids'
|
||||
SETTINGS_PATH = 'app.services.pricing_engine.settings'
|
||||
|
||||
|
||||
class TestFromPayloadLegacyRoundTrip:
|
||||
def test_legacy_to_payload_roundtrip(self):
|
||||
"""Legacy SubscriptionRenewalPricing.to_payload() -> from_payload() preserves all fields."""
|
||||
from app.services.subscription_renewal_service import SubscriptionRenewalPricing, build_renewal_period_id
|
||||
|
||||
original = SubscriptionRenewalPricing(
|
||||
period_days=30,
|
||||
period_id=build_renewal_period_id(30),
|
||||
months=1,
|
||||
base_original_total=15000,
|
||||
discounted_total=12000,
|
||||
final_total=10800,
|
||||
promo_discount_value=1200,
|
||||
promo_discount_percent=10,
|
||||
overall_discount_percent=28,
|
||||
per_month=10800,
|
||||
server_ids=[1, 2, 3],
|
||||
details={'servers_individual_prices': [5000, 3000, 2000]},
|
||||
)
|
||||
payload = original.to_payload()
|
||||
restored = SubscriptionRenewalPricing.from_payload(payload)
|
||||
|
||||
assert restored.period_days == original.period_days
|
||||
assert restored.period_id == original.period_id
|
||||
assert restored.months == original.months
|
||||
assert restored.base_original_total == original.base_original_total
|
||||
assert restored.discounted_total == original.discounted_total
|
||||
assert restored.final_total == original.final_total
|
||||
assert restored.promo_discount_value == original.promo_discount_value
|
||||
assert restored.promo_discount_percent == original.promo_discount_percent
|
||||
assert restored.overall_discount_percent == original.overall_discount_percent
|
||||
assert restored.per_month == original.per_month
|
||||
assert restored.server_ids == original.server_ids
|
||||
|
||||
|
||||
class TestOriginalPriceIdentity:
|
||||
@pytest.mark.asyncio
|
||||
async def test_tariff_mode_identity(self):
|
||||
"""final_total + promo_group_discount + promo_offer_discount == undiscounted subtotal."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
tariff = MagicMock()
|
||||
tariff.id = 1
|
||||
tariff.period_prices = {'30': 20000}
|
||||
tariff.device_price_kopeks = 3000
|
||||
tariff.device_limit = 1
|
||||
sub = MagicMock()
|
||||
sub.tariff_id = 1
|
||||
sub.tariff = tariff
|
||||
sub.device_limit = 3 # 2 extra
|
||||
user = MagicMock()
|
||||
promo_group = MagicMock()
|
||||
promo_group.get_discount_percent = MagicMock(return_value=25)
|
||||
user.promo_group = promo_group
|
||||
|
||||
with patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=15):
|
||||
result = await engine.calculate_renewal_price(db, sub, 30, user=user)
|
||||
|
||||
subtotal = 20000 + 2 * 3000 # 26000
|
||||
assert result.final_total + result.promo_group_discount + result.promo_offer_discount == subtotal
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classic_mode_identity(self):
|
||||
"""final_total + promo_group_discount + promo_offer_discount == undiscounted total in classic mode."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
sub = MagicMock()
|
||||
sub.tariff_id = None
|
||||
sub.tariff = None
|
||||
sub.connected_squads = ['uuid-s1']
|
||||
sub.traffic_limit_gb = 50
|
||||
sub.purchased_traffic_gb = 0
|
||||
sub.device_limit = 2 # 1 extra
|
||||
|
||||
user = MagicMock()
|
||||
promo_group = MagicMock()
|
||||
promo_group.get_discount_percent = MagicMock(return_value=20)
|
||||
user.promo_group = promo_group
|
||||
user.promo_group_id = 1
|
||||
|
||||
server = _make_server(price_kopeks=4000, squad_uuid='uuid-s1')
|
||||
|
||||
with (
|
||||
patch(SERVERS_BATCH_PATH, return_value=[server]),
|
||||
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=10),
|
||||
patch(SETTINGS_PATH) as ms,
|
||||
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
|
||||
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
|
||||
):
|
||||
ms.PRICE_PER_DEVICE = 5000
|
||||
ms.DEFAULT_DEVICE_LIMIT = 1
|
||||
ms.get_traffic_price.return_value = 3000
|
||||
ms.is_traffic_fixed.return_value = False
|
||||
ms.DEFAULT_TRAFFIC_LIMIT_GB = 50
|
||||
|
||||
result = await engine.calculate_renewal_price(db, sub, 30, user=user)
|
||||
|
||||
# Reconstruct original undiscounted total
|
||||
original = result.final_total + result.promo_group_discount + result.promo_offer_discount
|
||||
# original should equal base_original + servers_original + traffic_original + devices_original
|
||||
expected_original = 10000 + 4000 + 3000 + 5000 # 22000
|
||||
assert original == expected_original
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_original_total_property_tariff(self):
|
||||
"""original_total property returns correct value."""
|
||||
engine = PricingEngine()
|
||||
db = AsyncMock()
|
||||
tariff = MagicMock()
|
||||
tariff.id = 1
|
||||
tariff.period_prices = {'30': 20000}
|
||||
tariff.device_price_kopeks = None
|
||||
tariff.device_limit = 1
|
||||
sub = MagicMock()
|
||||
sub.tariff_id = 1
|
||||
sub.tariff = tariff
|
||||
sub.device_limit = 1
|
||||
user = MagicMock()
|
||||
promo_group = MagicMock()
|
||||
promo_group.get_discount_percent = MagicMock(return_value=10)
|
||||
user.promo_group = promo_group
|
||||
with patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=5):
|
||||
result = await engine.calculate_renewal_price(db, sub, 30, user=user)
|
||||
assert result.original_total == 20000 # undiscounted subtotal
|
||||
@@ -551,6 +551,7 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
|
||||
@@ -1115,7 +1116,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remnawave-bedolaga-telegram-bot"
|
||||
version = "3.28.0"
|
||||
version = "3.31.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiogram" },
|
||||
|
||||
Reference in New Issue
Block a user