Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 484d2f7e34 | |||
| 842fb697e6 | |||
| 3ac3a92e26 | |||
| 7648707ca2 | |||
| 7e466ef464 | |||
| 28321df4d2 | |||
| 6adf70b2da | |||
| 2d204275da | |||
| ebee8348ca | |||
| 06954c1711 | |||
| 5e04e2a020 | |||
| 08d69fb47f | |||
| 3306e02902 | |||
| 14dceaa39f | |||
| 5442f288d4 | |||
| 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 | |||
| 2f5674fcd7 | |||
| b9058e115a | |||
| 673afccb8c | |||
| 1badb39c49 | |||
| 23ff40cd2c |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.31.0"
|
||||
".": "3.32.3"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,86 @@
|
||||
# Changelog
|
||||
|
||||
## [3.32.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.2...v3.32.3) (2026-03-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* campaign registration, revenue calculation, backup restore, autopay errors, referral links ([7648707](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7648707ca26d6cd2703b50b0fe8c4697e6155784))
|
||||
* implement case-insensitive email checks in authentication and user retrieval ([7e466ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7e466ef464ce918d885bd6297d1e605a633fd43e))
|
||||
* implement case-insensitive email checks in authentication and user retrieval ([ebee834](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebee8348ca338b9be5f044537e5e2b4740dc6441))
|
||||
* **payment:** prioritize saved cart after topup over expired auto-extend ([28321df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28321df4d274269536efebcf3da870f2e7d07d90))
|
||||
* refresh CLASSIC_PERIOD_PRICES when admin changes PRICE_*_DAYS or SALES_MODE ([6adf70b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6adf70b2da6e2250cc8e909dbb497b355302e72f))
|
||||
|
||||
## [3.32.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.1...v3.32.2) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add nested selectinload and referrer eager loading to prevent MissingGreenlet ([3306e02](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3306e029021c396e13774a205225beece4fbbcfb))
|
||||
* add selectinload to user lock queries to prevent MissingGreenlet ([5442f28](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5442f288d4c6c3973dd92ac141172a9f0e53a28f))
|
||||
* silence PARTICIPANT_ID_INVALID error in channel subscription check ([14dceaa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/14dceaa39ff9faa1c9205483653014a1c5ac73fb))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
+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.31.0" # x-release-please-version
|
||||
ARG VERSION="v3.32.3" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ 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,
|
||||
@@ -1122,26 +1123,18 @@ async def update_user_subscription(
|
||||
)
|
||||
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
extra_devices = 0
|
||||
if subscription.tariff_id:
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if old_tariff and old_tariff.device_limit:
|
||||
extra_devices = max(0, (subscription.device_limit or old_tariff.device_limit) - old_tariff.device_limit)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
from app.config import settings
|
||||
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
|
||||
|
||||
new_base = tariff.device_limit or 1
|
||||
new_total = new_base + extra_devices
|
||||
# Cap at new tariff's max_device_limit, falling back to global MAX_DEVICES_LIMIT
|
||||
effective_max = tariff.max_device_limit or (
|
||||
settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
|
||||
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,
|
||||
)
|
||||
if effective_max and new_total > effective_max:
|
||||
new_total = effective_max
|
||||
subscription.device_limit = new_total
|
||||
# Set squads from tariff
|
||||
if tariff.allowed_squads:
|
||||
subscription.connected_squads = tariff.allowed_squads
|
||||
@@ -2523,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(),
|
||||
|
||||
@@ -6,7 +6,7 @@ from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -721,8 +721,9 @@ async def register_email(
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
existing_user = await db.execute(select(User).where(User.email == request.email))
|
||||
# Check if email already exists (case-insensitive)
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
existing_user = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
if existing_user.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -837,8 +838,9 @@ async def register_email_standalone(
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Проверить что email не занят
|
||||
existing = await db.execute(select(User).where(User.email == request.email))
|
||||
# Проверить что email не занят (без учёта регистра)
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
existing = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -1096,8 +1098,9 @@ async def login_email(
|
||||
# Check if this is a test email login
|
||||
is_test_email = settings.is_test_email(request.email)
|
||||
|
||||
# Find user by email
|
||||
result = await db.execute(select(User).where(User.email == request.email))
|
||||
# Find user by email (case-insensitive)
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
@@ -1317,7 +1320,8 @@ async def forgot_password(
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
result = await db.execute(select(User).where(User.email == request.email))
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# Always return success to prevent email enumeration
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -91,12 +91,20 @@ async def get_referral_info(
|
||||
referral_entitlement = max(0, total_earnings - withdrawn - pending)
|
||||
available_balance = min(user.balance_kopeks, referral_entitlement)
|
||||
|
||||
# Build referral link
|
||||
# Build referral links
|
||||
referral_link = settings.get_referral_link(user.referral_code) if user.referral_code else ''
|
||||
bot_username = settings.get_bot_username()
|
||||
bot_referral_link = ''
|
||||
if user.referral_code and bot_username:
|
||||
from urllib.parse import quote
|
||||
|
||||
safe_code = quote(user.referral_code, safe='')
|
||||
bot_referral_link = f'https://t.me/{bot_username}?start={safe_code}'
|
||||
|
||||
return ReferralInfoResponse(
|
||||
referral_code=user.referral_code or '',
|
||||
referral_link=referral_link,
|
||||
bot_referral_link=bot_referral_link,
|
||||
total_referrals=total_referrals,
|
||||
active_referrals=active_referrals,
|
||||
total_earnings_kopeks=total_earnings,
|
||||
|
||||
+132
-232
@@ -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,
|
||||
}
|
||||
|
||||
@@ -4175,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':
|
||||
@@ -4337,6 +4203,7 @@ async def switch_tariff(
|
||||
upgrade_cost,
|
||||
description,
|
||||
mark_as_paid_subscription=True,
|
||||
commit=False,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
@@ -4344,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,
|
||||
@@ -4352,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
|
||||
@@ -4362,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='смена тарифа',
|
||||
)
|
||||
@@ -4429,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:
|
||||
@@ -4444,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,
|
||||
@@ -4460,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,
|
||||
@@ -4516,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,
|
||||
|
||||
@@ -10,6 +10,7 @@ class ReferralInfoResponse(BaseModel):
|
||||
|
||||
referral_code: str
|
||||
referral_link: str
|
||||
bot_referral_link: str = ''
|
||||
total_referrals: int
|
||||
active_referrals: int
|
||||
total_earnings_kopeks: int
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,7 @@ class SubscriptionStatusEnum(StrEnum):
|
||||
ACTIVE = 'active'
|
||||
EXPIRED = 'expired'
|
||||
DISABLED = 'disabled'
|
||||
LIMITED = 'limited'
|
||||
PENDING = 'pending'
|
||||
|
||||
|
||||
|
||||
@@ -2700,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}
|
||||
|
||||
@@ -366,7 +366,8 @@ async def get_campaign_statistics(
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
|
||||
total_revenue = deposits_total + subscription_payments_total
|
||||
# Revenue = only real deposits (exclude bonus-funded subscription spending)
|
||||
total_revenue = deposits_total
|
||||
|
||||
paid_user_ids = set(paid_users_from_transactions)
|
||||
paid_user_ids.update(conversion_user_ids)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -780,7 +811,11 @@ async def reactivate_subscription(db: AsyncSession, subscription: Subscription)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Тихо выходим если реактивация не нужна (уже активна или другой статус)
|
||||
reactivatable_statuses = {SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value}
|
||||
reactivatable_statuses = {
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
}
|
||||
if subscription.status not in reactivatable_statuses:
|
||||
return subscription
|
||||
|
||||
@@ -1376,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
|
||||
@@ -1425,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)
|
||||
@@ -2215,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
|
||||
# Обновляем время последнего списания для корректного расчёта следующего
|
||||
|
||||
+89
-23
@@ -410,6 +410,28 @@ 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.
|
||||
Eagerly loads key relationships to avoid MissingGreenlet in async context.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == user.id)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def add_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
@@ -421,6 +443,22 @@ async def add_user_balance(
|
||||
payment_method: PaymentMethod | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
# Lock the user row to prevent concurrent balance race conditions
|
||||
# Eagerly load key relationships to avoid MissingGreenlet in async context
|
||||
locked_result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == user.id)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.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,17 +539,33 @@ 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
|
||||
# Eagerly load key relationships to avoid MissingGreenlet in async context
|
||||
locked_result = await db.execute(
|
||||
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
|
||||
select(User)
|
||||
.where(User.id == user.id)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
@@ -572,8 +626,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 +633,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 +654,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:
|
||||
@@ -1131,8 +1191,11 @@ async def create_user_by_email(
|
||||
|
||||
|
||||
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
"""Get user by email address."""
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
"""Get user by email address (case-insensitive)."""
|
||||
if not email or not email.strip():
|
||||
return None
|
||||
email_lower = email.strip().lower()
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -1148,7 +1211,10 @@ async def is_email_taken(db: AsyncSession, email: str, exclude_user_id: int | No
|
||||
Returns:
|
||||
True if email is taken, False otherwise
|
||||
"""
|
||||
query = select(User.id).where(User.email == email)
|
||||
if not email or not email.strip():
|
||||
return False
|
||||
email_lower = email.strip().lower()
|
||||
query = select(User.id).where(func.lower(User.email) == email_lower)
|
||||
if exclude_user_id:
|
||||
query = query.where(User.id != exclude_user_id)
|
||||
result = await db.execute(query)
|
||||
|
||||
@@ -123,6 +123,7 @@ class SubscriptionStatus(Enum):
|
||||
ACTIVE = 'active'
|
||||
EXPIRED = 'expired'
|
||||
DISABLED = 'disabled'
|
||||
LIMITED = 'limited'
|
||||
PENDING = 'pending'
|
||||
|
||||
|
||||
@@ -1350,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'
|
||||
@@ -1374,6 +1378,8 @@ class Subscription(Base):
|
||||
return '🟢 Активна'
|
||||
if actual_status == 'disabled':
|
||||
return '⚫ Отключена'
|
||||
if actual_status == 'limited':
|
||||
return '⚠️ Трафик исчерпан'
|
||||
if actual_status == 'trial':
|
||||
return '🎯 Тестовая'
|
||||
|
||||
@@ -1391,6 +1397,8 @@ class Subscription(Base):
|
||||
return '💎'
|
||||
if actual_status == 'disabled':
|
||||
return '⚫'
|
||||
if actual_status == 'limited':
|
||||
return '⚠️'
|
||||
if actual_status == 'trial':
|
||||
return '🎁'
|
||||
|
||||
@@ -1439,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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = ' Пакеты не настроены'
|
||||
|
||||
@@ -1210,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:
|
||||
@@ -2712,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'
|
||||
|
||||
@@ -4173,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:
|
||||
@@ -4183,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
|
||||
|
||||
@@ -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,
|
||||
@@ -879,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,
|
||||
|
||||
@@ -302,6 +302,9 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte
|
||||
language = data.get('language') or (getattr(user, 'language', None) if user else None) or DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
if not message.text:
|
||||
return False
|
||||
|
||||
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
|
||||
|
||||
potential_code = message.text.strip()
|
||||
@@ -754,6 +757,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
|
||||
@@ -1179,6 +1189,10 @@ async def process_referral_code_input(message: types.Message, state: FSMContext,
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
if not message.text:
|
||||
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
|
||||
return
|
||||
|
||||
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
|
||||
|
||||
code = message.text.strip()
|
||||
@@ -1344,6 +1358,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 +1660,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 +1925,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', '⏳ Ожидает активации')
|
||||
|
||||
@@ -2317,6 +2348,33 @@ async def required_sub_channel_check(
|
||||
except Exception as e:
|
||||
logger.error('Ошибка при обработке реферальной регистрации', error=e)
|
||||
|
||||
# Применяем бонус рекламной кампании (record_campaign_registration)
|
||||
campaign_message = await _apply_campaign_bonus_if_needed(db, user, state_data, texts)
|
||||
try:
|
||||
await db.refresh(user)
|
||||
except Exception as refresh_error:
|
||||
logger.error(
|
||||
'Ошибка обновления данных пользователя после бонуса кампании',
|
||||
telegram_id=user.telegram_id,
|
||||
refresh_error=refresh_error,
|
||||
)
|
||||
try:
|
||||
await db.refresh(user, ['subscription'])
|
||||
except Exception as refresh_sub_error:
|
||||
logger.error(
|
||||
'Ошибка обновления подписки после бонуса кампании',
|
||||
telegram_id=user.telegram_id,
|
||||
refresh_sub_error=refresh_sub_error,
|
||||
)
|
||||
if campaign_message:
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text=campaign_message,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки сообщения о бонусе кампании', error=e)
|
||||
|
||||
# Показываем главное меню после создания пользователя
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
from aiogram import types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
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,
|
||||
)
|
||||
@@ -107,7 +115,13 @@ async def toggle_autopay(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
status = texts.t('AUTOPAY_STATUS_ENABLED', 'включен') if enable else texts.t('AUTOPAY_STATUS_DISABLED', 'выключен')
|
||||
await callback.answer(texts.t('AUTOPAY_TOGGLE_SUCCESS', '✅ Автоплатеж {status}!').format(status=status))
|
||||
|
||||
await handle_autopay_menu(callback, db_user, db)
|
||||
try:
|
||||
await handle_autopay_menu(callback, db_user, db)
|
||||
except TelegramBadRequest as e:
|
||||
if 'message is not modified' in str(e):
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def show_autopay_days(callback: types.CallbackQuery, db_user: User):
|
||||
@@ -134,6 +148,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
|
||||
):
|
||||
|
||||
@@ -473,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 '⌛ Выключен',
|
||||
|
||||
@@ -97,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,
|
||||
@@ -118,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,
|
||||
@@ -209,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 = '⏸️'
|
||||
@@ -1591,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 = {}
|
||||
@@ -1599,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,
|
||||
}
|
||||
|
||||
@@ -1724,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)}',
|
||||
]
|
||||
|
||||
@@ -1766,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)
|
||||
|
||||
@@ -1783,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:
|
||||
@@ -1956,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,
|
||||
}
|
||||
@@ -1975,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()
|
||||
|
||||
@@ -3152,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,
|
||||
@@ -3216,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
|
||||
|
||||
# При возобновлении проверяем баланс
|
||||
@@ -4548,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
+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(
|
||||
|
||||
@@ -1105,9 +1105,20 @@ class BackupService:
|
||||
existing = existing_user.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
for key, value in processed_data.items():
|
||||
if key != 'id':
|
||||
setattr(existing, key, value)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
for key, value in processed_data.items():
|
||||
if key != 'id':
|
||||
setattr(existing, key, value)
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
db.expire(existing)
|
||||
logger.warning(
|
||||
'Конфликт уникального ключа при обновлении пользователя, пропускаем',
|
||||
user_id=processed_data.get('id'),
|
||||
telegram_id=processed_data.get('telegram_id'),
|
||||
)
|
||||
continue
|
||||
else:
|
||||
instance = User(**processed_data)
|
||||
try:
|
||||
@@ -1376,9 +1387,20 @@ class BackupService:
|
||||
existing = existing_record.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
for key, value in processed_data.items():
|
||||
if key not in pk_cols:
|
||||
setattr(existing, key, value)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
for key, value in processed_data.items():
|
||||
if key not in pk_cols:
|
||||
setattr(existing, key, value)
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
db.expire(existing)
|
||||
logger.warning(
|
||||
'Конфликт уникального ключа при обновлении записи, пропускаем',
|
||||
table_name=table_name,
|
||||
pk={col: processed_data.get(col) for col in pk_cols},
|
||||
)
|
||||
continue
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
try:
|
||||
|
||||
@@ -274,8 +274,9 @@ class ChannelSubscriptionService:
|
||||
)
|
||||
return False # Fail-closed -- bot cannot verify membership
|
||||
except TelegramBadRequest as e:
|
||||
if 'user not found' in str(e).lower():
|
||||
return False # User never interacted with bot in that context
|
||||
err_msg = str(e).lower()
|
||||
if 'user not found' in err_msg or 'participant_id_invalid' in err_msg:
|
||||
return False # User never interacted with bot/channel
|
||||
logger.error('Bad request checking channel', channel_id=channel_id, error=str(e))
|
||||
return False # Fail-closed
|
||||
except TelegramNetworkError:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
'Ошибка расчёта стоимости автопродления, пропускаем',
|
||||
|
||||
@@ -939,8 +939,7 @@ class PartnerStatsService:
|
||||
registrations_dict = {str(row.date): int(row.count) for row in registrations_by_day.all()}
|
||||
|
||||
# --- Daily revenue (DAILY_STATS_DAYS days) ---
|
||||
# Revenue = real deposits (positive) + abs(subscription_payments) (stored negative)
|
||||
# Exclude promo/bonus deposits (payment_method IS NULL) from revenue
|
||||
# Revenue = real deposits only (exclude bonus/promo balance spending on subscriptions)
|
||||
revenue_amount_expr = func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
@@ -951,10 +950,6 @@ class PartnerStatsService:
|
||||
),
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
func.abs(Transaction.amount_kopeks),
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
@@ -971,12 +966,8 @@ class PartnerStatsService:
|
||||
Transaction.user_id.in_(campaign_user_ids_sq),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.DEPOSIT.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
.group_by(func.date(Transaction.created_at))
|
||||
@@ -1027,12 +1018,8 @@ class PartnerStatsService:
|
||||
Transaction.user_id.in_(campaign_user_ids_sq),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.created_at >= week_ago,
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.DEPOSIT.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1046,12 +1033,8 @@ class PartnerStatsService:
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.created_at >= previous_start,
|
||||
Transaction.created_at < week_ago,
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.DEPOSIT.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+106
-99
@@ -325,7 +325,111 @@ async def send_cart_notification_after_topup(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Try to auto-extend expired subscription (works without cart)
|
||||
cart_data = await user_cart_service.get_user_cart(user.id)
|
||||
# В приоритете всегда сохраненная корзина: она отражает явный выбор пользователя
|
||||
# (период/тариф/сумма). Автопродление expired — только когда корзины нет.
|
||||
if cart_data:
|
||||
cart_total = cart_data.get('total_price', 0)
|
||||
if not cart_total:
|
||||
logger.warning(
|
||||
'Сохраненная корзина найдена, но total_price отсутствует или некорректен',
|
||||
user_id=user.id,
|
||||
cart_total=cart_total,
|
||||
)
|
||||
return False
|
||||
|
||||
# Try auto-purchase first
|
||||
auto_purchase_success = False
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=bot)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
'Ошибка автоматической покупки подписки для пользователя',
|
||||
user_id=user.id,
|
||||
auto_error=auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
return False
|
||||
|
||||
if not bot or not getattr(user, 'telegram_id', None):
|
||||
return False
|
||||
|
||||
# Refresh balance from DB to account for any changes during auto-purchase attempt
|
||||
refreshed_user = await get_user_by_id(db, user.id)
|
||||
balance = getattr(refreshed_user or user, 'balance_kopeks', 0)
|
||||
|
||||
texts = get_texts(getattr(user, 'language', 'ru'))
|
||||
|
||||
# Build message based on whether balance is sufficient
|
||||
fmt = settings.format_price
|
||||
cart_total_formatted = fmt(cart_total)
|
||||
if balance >= cart_total:
|
||||
template = texts.get('BALANCE_TOPPED_UP_CART_SUFFICIENT', '')
|
||||
message_text = template.format(
|
||||
amount=fmt(amount_kopeks),
|
||||
balance=fmt(balance),
|
||||
cart_total=cart_total_formatted,
|
||||
total_amount=cart_total_formatted,
|
||||
)
|
||||
else:
|
||||
missing = cart_total - balance
|
||||
template = texts.get('BALANCE_TOPPED_UP_CART_INSUFFICIENT', '')
|
||||
message_text = template.format(
|
||||
amount=fmt(amount_kopeks),
|
||||
balance=fmt(balance),
|
||||
cart_total=cart_total_formatted,
|
||||
total_amount=cart_total_formatted,
|
||||
missing=fmt(missing),
|
||||
)
|
||||
|
||||
if not message_text:
|
||||
logger.warning('Missing cart notification template', language=getattr(user, 'language', 'ru'))
|
||||
return False
|
||||
|
||||
sent = False
|
||||
try:
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('RETURN_TO_SUBSCRIPTION_CHECKOUT', '⬅️ Checkout'),
|
||||
callback_data='return_to_saved_cart',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('MY_BALANCE_BUTTON', '💰 Balance'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('MAIN_MENU_BUTTON', '🏠 Menu'),
|
||||
callback_data='back_to_menu',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
sent = True
|
||||
logger.info('Sent cart notification to user', user_id=user.id)
|
||||
except Exception as send_error:
|
||||
logger.error(
|
||||
'Failed to send cart notification to user',
|
||||
user_id=user.id,
|
||||
error=send_error,
|
||||
)
|
||||
|
||||
return sent
|
||||
|
||||
# Try to auto-extend expired subscription only when there is no saved cart.
|
||||
try:
|
||||
auto_extended = await try_auto_extend_expired_after_topup(db, user, bot=bot)
|
||||
if auto_extended:
|
||||
@@ -338,104 +442,7 @@ async def send_cart_notification_after_topup(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
cart_data = await user_cart_service.get_user_cart(user.id)
|
||||
if not cart_data:
|
||||
return False
|
||||
|
||||
cart_total = cart_data.get('total_price', 0)
|
||||
if not cart_total:
|
||||
return False
|
||||
|
||||
# Try auto-purchase first
|
||||
auto_purchase_success = False
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=bot)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
'Ошибка автоматической покупки подписки для пользователя',
|
||||
user_id=user.id,
|
||||
auto_error=auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
return False
|
||||
|
||||
if not bot or not getattr(user, 'telegram_id', None):
|
||||
return False
|
||||
|
||||
# Refresh balance from DB to account for any changes during auto-purchase attempt
|
||||
refreshed_user = await get_user_by_id(db, user.id)
|
||||
balance = getattr(refreshed_user or user, 'balance_kopeks', 0)
|
||||
|
||||
texts = get_texts(getattr(user, 'language', 'ru'))
|
||||
|
||||
# Build message based on whether balance is sufficient
|
||||
fmt = settings.format_price
|
||||
cart_total_formatted = fmt(cart_total)
|
||||
if balance >= cart_total:
|
||||
template = texts.get('BALANCE_TOPPED_UP_CART_SUFFICIENT', '')
|
||||
message_text = template.format(
|
||||
amount=fmt(amount_kopeks),
|
||||
balance=fmt(balance),
|
||||
cart_total=cart_total_formatted,
|
||||
total_amount=cart_total_formatted,
|
||||
)
|
||||
else:
|
||||
missing = cart_total - balance
|
||||
template = texts.get('BALANCE_TOPPED_UP_CART_INSUFFICIENT', '')
|
||||
message_text = template.format(
|
||||
amount=fmt(amount_kopeks),
|
||||
balance=fmt(balance),
|
||||
cart_total=cart_total_formatted,
|
||||
total_amount=cart_total_formatted,
|
||||
missing=fmt(missing),
|
||||
)
|
||||
|
||||
if not message_text:
|
||||
logger.warning('Missing cart notification template', language=getattr(user, 'language', 'ru'))
|
||||
return False
|
||||
|
||||
sent = False
|
||||
try:
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('RETURN_TO_SUBSCRIPTION_CHECKOUT', '⬅️ Checkout'),
|
||||
callback_data='return_to_saved_cart',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('MY_BALANCE_BUTTON', '💰 Balance'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('MAIN_MENU_BUTTON', '🏠 Menu'),
|
||||
callback_data='back_to_menu',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
sent = True
|
||||
logger.info('Sent cart notification to user', user_id=user.id)
|
||||
except Exception as send_error:
|
||||
logger.error(
|
||||
'Failed to send cart notification to user',
|
||||
user_id=user.id,
|
||||
error=send_error,
|
||||
)
|
||||
|
||||
return sent
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
@@ -1513,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),
|
||||
@@ -1574,6 +1612,30 @@ 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/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
@@ -1739,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: ошибка расчёта стоимости',
|
||||
@@ -1757,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: некорректная стоимость',
|
||||
@@ -1803,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:
|
||||
@@ -1855,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
|
||||
@@ -2016,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
|
||||
@@ -2109,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(
|
||||
@@ -2283,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 секунд,
|
||||
@@ -2302,8 +2455,8 @@ async def auto_purchase_saved_cart_after_topup(
|
||||
format_user_id=_format_user_id(user),
|
||||
total_seconds=(datetime.now(UTC) - last_tx.created_at).total_seconds(),
|
||||
)
|
||||
# Очищаем корзину чтобы не срабатывало повторно
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
# Корзину не очищаем: транзакция могла быть из другого потока
|
||||
# (например, фоновое автопродление), чтобы не потерять явный выбор пользователя.
|
||||
return False
|
||||
except Exception as check_error:
|
||||
logger.warning(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.config import (
|
||||
ENV_OVERRIDE_KEYS,
|
||||
Settings,
|
||||
clear_db_period_prices,
|
||||
refresh_classic_period_prices,
|
||||
refresh_period_prices,
|
||||
refresh_traffic_prices,
|
||||
settings,
|
||||
@@ -1517,6 +1518,7 @@ class BotConfigurationService:
|
||||
# т.к. ensure_tariffs_synced мог загрузить тарифные цены до того как
|
||||
# SALES_MODE=classic был применён из system_settings
|
||||
refresh_period_prices()
|
||||
refresh_classic_period_prices()
|
||||
|
||||
@classmethod
|
||||
async def reload(cls) -> None:
|
||||
@@ -1673,6 +1675,7 @@ class BotConfigurationService:
|
||||
if settings.is_classic_mode():
|
||||
clear_db_period_prices()
|
||||
refresh_period_prices()
|
||||
refresh_classic_period_prices()
|
||||
elif key in {
|
||||
'PRICE_14_DAYS',
|
||||
'PRICE_30_DAYS',
|
||||
@@ -1682,6 +1685,7 @@ class BotConfigurationService:
|
||||
'PRICE_360_DAYS',
|
||||
}:
|
||||
refresh_period_prices()
|
||||
refresh_classic_period_prices()
|
||||
elif key.startswith('PRICE_TRAFFIC_') or key == 'TRAFFIC_PACKAGES_CONFIG':
|
||||
refresh_traffic_prices()
|
||||
elif key in {'REMNAWAVE_AUTO_SYNC_ENABLED', 'REMNAWAVE_AUTO_SYNC_TIMES'}:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -24,6 +24,8 @@ def format_referrer_info(user: User) -> str:
|
||||
|
||||
try:
|
||||
# Проверяем, является ли referrer обычным объектом или InstrumentedList
|
||||
# getattr default does NOT catch MissingGreenlet (not an AttributeError),
|
||||
# so we wrap in try/except to handle lazy-load failures in async context.
|
||||
referrer = getattr(user, 'referrer', None)
|
||||
|
||||
# Если referrer это InstrumentedList или None, то возвращаем информацию по ID
|
||||
@@ -39,8 +41,9 @@ def format_referrer_info(user: User) -> str:
|
||||
|
||||
return f'ID {referrer_telegram_id or referred_by_id}'
|
||||
|
||||
except (AttributeError, TypeError):
|
||||
# Если возникла ошибка при обращении к атрибутам, просто возвращаем ID
|
||||
except Exception:
|
||||
# MissingGreenlet is not a subclass of AttributeError/TypeError,
|
||||
# so we must catch broadly to handle lazy-load failures in async context.
|
||||
return f'ID {referred_by_id} (ошибка загрузки)'
|
||||
|
||||
|
||||
|
||||
+200
-318
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Collection
|
||||
@@ -4497,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
|
||||
@@ -4815,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',
|
||||
@@ -5217,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(
|
||||
@@ -5238,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:
|
||||
@@ -5501,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)
|
||||
|
||||
@@ -6619,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
|
||||
@@ -6632,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:
|
||||
@@ -6646,8 +6488,6 @@ async def purchase_tariff_endpoint(
|
||||
},
|
||||
)
|
||||
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
|
||||
# Списываем баланс
|
||||
if is_daily_tariff:
|
||||
description = f"Активация суточного тарифа '{tariff.name}' (первый день)"
|
||||
@@ -6660,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:
|
||||
@@ -6691,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,
|
||||
@@ -6698,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:
|
||||
@@ -6783,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(
|
||||
@@ -6959,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,
|
||||
@@ -7045,6 +6900,7 @@ async def switch_tariff_endpoint(
|
||||
upgrade_cost,
|
||||
description,
|
||||
mark_as_paid_subscription=True,
|
||||
commit=False,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
@@ -7053,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,
|
||||
)
|
||||
|
||||
# Получаем список серверов из тарифа
|
||||
@@ -7082,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
|
||||
@@ -7125,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)
|
||||
|
||||
@@ -7389,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.31.0"
|
||||
version = "3.32.3"
|
||||
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
|
||||
Reference in New Issue
Block a user