diff --git a/app/cabinet/routes/admin_promocodes.py b/app/cabinet/routes/admin_promocodes.py index 5562b519..c3fc02d4 100644 --- a/app/cabinet/routes/admin_promocodes.py +++ b/app/cabinet/routes/admin_promocodes.py @@ -489,44 +489,63 @@ async def admin_deactivate_discount_promocode( admin: User = Depends(require_permission('promocodes:edit')), db: AsyncSession = Depends(get_cabinet_db), ) -> DeactivateDiscountResponse: - """Admin: deactivate a user's active discount promo code.""" + """Admin: deactivate a user's active discount (promo code or promo offer).""" from app.database.crud.user import get_user_by_id as get_user target_user = await get_user(db, user_id) if not target_user: raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') - from app.services.promocode_service import PromoCodeService + current_discount = getattr(target_user, 'promo_offer_discount_percent', 0) or 0 + source = getattr(target_user, 'promo_offer_discount_source', None) - service = PromoCodeService() - result = await service.deactivate_discount_promocode( - db=db, - user_id=user_id, - admin_initiated=True, - ) + if current_discount <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'User has no active discount') - if result['success']: - return DeactivateDiscountResponse( - success=True, - message=f'Discount promo code deactivated for user {user_id}', - deactivated_code=result.get('deactivated_code'), - discount_percent=result.get('discount_percent', 0), + # If source is a promo code, use the service to properly rollback usage + if source and source.startswith('promocode:'): + from app.services.promocode_service import PromoCodeService + + service = PromoCodeService() + result = await service.deactivate_discount_promocode( + db=db, user_id=user_id, + admin_initiated=True, ) - error_messages = { - 'user_not_found': 'User not found', - 'no_active_discount_promocode': 'User has no active discount from a promo code', - 'discount_already_expired': 'Discount has already expired (cleaned up)', - 'server_error': 'Server error occurred', - } + if result['success']: + return DeactivateDiscountResponse( + success=True, + message=f'Discount promo code deactivated for user {user_id}', + deactivated_code=result.get('deactivated_code'), + discount_percent=result.get('discount_percent', 0), + user_id=user_id, + ) - error_code = result.get('error', 'server_error') - error_message = error_messages.get(error_code, 'Failed to deactivate promo code') + error_messages = { + 'user_not_found': 'User not found', + 'no_active_discount_promocode': 'User has no active discount from a promo code', + 'discount_already_expired': 'Discount has already expired (cleaned up)', + 'server_error': 'Server error occurred', + } - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=error_message, + error_code = result.get('error', 'server_error') + raise HTTPException(status.HTTP_400_BAD_REQUEST, error_messages.get(error_code, 'Failed to deactivate')) + + # For non-promocode offers (admin offers, etc.) — just clear the fields + old_percent = target_user.promo_offer_discount_percent + target_user.promo_offer_discount_percent = 0 + target_user.promo_offer_discount_source = None + target_user.promo_offer_discount_expires_at = None + target_user.updated_at = datetime.now(UTC) + await db.commit() + + return DeactivateDiscountResponse( + success=True, + message=f'Promo offer deactivated for user {user_id}', + deactivated_code=None, + discount_percent=old_percent, + user_id=user_id, ) diff --git a/app/cabinet/routes/admin_stats.py b/app/cabinet/routes/admin_stats.py index 63763ee7..de489a82 100644 --- a/app/cabinet/routes/admin_stats.py +++ b/app/cabinet/routes/admin_stats.py @@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database.crud.campaign import get_campaign_statistics, get_campaigns_count, get_campaigns_list from app.database.crud.server_squad import get_server_statistics from app.database.crud.subscription import get_subscriptions_statistics -from app.database.crud.transaction import get_revenue_by_period, get_transactions_statistics +from app.database.crud.transaction import REAL_PAYMENT_METHODS, get_revenue_by_period, get_transactions_statistics from app.database.models import ( ReferralEarning, Subscription, @@ -931,6 +931,7 @@ async def get_recent_payments( Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, Transaction.created_at >= today_start, + Transaction.payment_method.in_(REAL_PAYMENT_METHODS), ) ) ) @@ -942,6 +943,7 @@ async def get_recent_payments( Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, Transaction.created_at >= week_ago, + Transaction.payment_method.in_(REAL_PAYMENT_METHODS), ) ) ) diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index a752d691..2d07792c 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -610,14 +610,14 @@ async def get_user_detail( transactions_result = await db.execute(transactions_q) transactions = transactions_result.scalars().all() - _EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value} + _EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value, TransactionType.GIFT_PAYMENT.value} recent_transactions = [ UserTransactionItem( id=t.id, type=t.type, - amount_kopeks=abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks, - amount_rubles=abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100, + amount_kopeks=-abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks, + amount_rubles=-abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100, description=t.description, payment_method=t.payment_method, is_completed=t.is_completed, @@ -2163,14 +2163,14 @@ async def get_user_transactions( result = await db.execute(query) transactions = result.scalars().all() - _EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value} + _EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value, TransactionType.GIFT_PAYMENT.value} items = [ UserTransactionItem( id=t.id, type=t.type, - amount_kopeks=abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks, - amount_rubles=abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100, + amount_kopeks=-abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks, + amount_rubles=-abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100, description=t.description, payment_method=t.payment_method, is_completed=t.is_completed, diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index 5554cb7a..0951efd3 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -102,8 +102,8 @@ async def get_transactions( for t in transactions: # Determine sign based on transaction type # Credits (positive): DEPOSIT, REFERRAL_REWARD, REFUND, POLL_REWARD - # Debits (negative): SUBSCRIPTION_PAYMENT, WITHDRAWAL - is_debit = t.type in ['subscription_payment', 'withdrawal'] + # Debits (negative): SUBSCRIPTION_PAYMENT, WITHDRAWAL, GIFT_PAYMENT + is_debit = t.type in ['subscription_payment', 'withdrawal', 'gift_payment'] amount_kopeks = -abs(t.amount_kopeks) if is_debit else abs(t.amount_kopeks) items.append( diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index c433972b..839788a6 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -98,12 +98,13 @@ def _apply_addon_discount( Returns dict with keys: discounted, discount, percent """ + from app.utils.pricing_utils import apply_percentage_discount + percent = _get_addon_discount_percent(user, category, period_days) if percent <= 0 or amount <= 0: return {'discounted': amount, 'discount': 0, 'percent': 0} - discount_value = int(amount * percent / 100) - discounted_amount = amount - discount_value + discounted_amount, discount_value = apply_percentage_discount(amount, percent) return { 'discounted': discounted_amount, 'discount': discount_value, @@ -860,15 +861,15 @@ async def purchase_traffic( # Пропорциональный расчёт применяем только в классическом режиме. if is_tariff_mode: prorated_price = base_price_kopeks - months_charged = 1 + days_charged = 30 else: - prorated_price, months_charged = calculate_prorated_price( + prorated_price, days_charged = calculate_prorated_price( base_price_kopeks, subscription.end_date, ) # Apply discount from promo group using proper method - period_hint_days = months_charged * 30 if months_charged > 0 else 30 + period_hint_days = days_charged if days_charged > 0 else 30 discount_result = _apply_addon_discount(user, 'traffic', prorated_price, period_hint_days) final_price = discount_result['discounted'] traffic_discount_percent = discount_result['percent'] @@ -2644,7 +2645,6 @@ async def save_traffic_cart( db: AsyncSession = Depends(get_cabinet_db), ) -> dict[str, bool]: """Save cart for traffic purchase (for insufficient balance flow).""" - from app.utils.pricing_utils import calculate_prorated_price await db.refresh(user, ['subscription']) subscription = user.subscription @@ -2715,26 +2715,18 @@ async def save_traffic_cart( ) base_price_kopeks = matching_pkg['price'] - # Apply promo group discount - traffic_discount_percent = 0 - promo_group = ( - user.get_primary_promo_group() - if hasattr(user, 'get_primary_promo_group') - else getattr(user, 'promo_group', None) - ) - if promo_group: - apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) - if apply_to_addons: - traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))) + # Calculate prorated price (days-based), then apply discount + from app.utils.pricing_utils import calculate_prorated_price as _calc_prorated - if traffic_discount_percent > 0: - base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100) - - # Calculate prorated price - final_price, _ = calculate_prorated_price( + now = datetime.now(UTC) + days_left = max(1, (subscription.end_date - now).days) + prorated_price, _ = _calc_prorated( base_price_kopeks, subscription.end_date, ) + discount_result = _apply_addon_discount(user, 'traffic', prorated_price, days_left) + final_price = discount_result['discounted'] + traffic_discount_percent = discount_result['percent'] # Save cart for auto-purchase after balance top-up cart_data = { @@ -2812,14 +2804,26 @@ async def save_devices_cart( days_left = max(1, (end_date - now).days) total_days = 30 - price_kopeks = int(device_price * request.devices * days_left / total_days) - price_kopeks = max(100, price_kopeks) # Minimum 1 ruble + base_total_price = int(device_price * request.devices * days_left / total_days) + base_total_price = max(100, base_total_price) # Minimum 1 ruble + + # Apply discount from promo group + period_hint_days = days_left + discount_result = _apply_addon_discount(user, 'devices', base_total_price, period_hint_days) + price_kopeks = discount_result['discounted'] + devices_discount_percent = discount_result['percent'] + + # Ensure minimum price after discount (except for 100% discount) + if devices_discount_percent < 100 and price_kopeks > 0: + price_kopeks = max(100, price_kopeks) # Save cart for auto-purchase after balance top-up cart_data = { 'cart_mode': 'add_devices', 'devices_to_add': request.devices, 'price_kopeks': price_kopeks, + 'base_price_kopeks': base_total_price, + 'discount_percent': devices_discount_percent, 'source': 'cabinet', } await user_cart_service.save_user_cart(user.id, cart_data) @@ -2897,10 +2901,9 @@ async def get_device_price( days_left = max(1, (end_date - now).days) total_days = 30 - # Calculate base price before discount - base_price_per_device = int(device_price * days_left / total_days) - base_price_per_device = max(100, base_price_per_device) - base_total_price = base_price_per_device * devices + # Calculate base price before discount (total first, then floor) + base_total_price = int(device_price * devices * days_left / total_days) + base_total_price = max(100, base_total_price) # Apply discount from promo group period_hint_days = days_left @@ -2909,7 +2912,7 @@ async def get_device_price( devices_discount_percent = discount_result['percent'] discount_value = discount_result['discount'] - # Calculate per-device price after discount + # Ensure minimum price after discount (except for 100% discount) if devices_discount_percent < 100 and total_price_kopeks > 0: total_price_kopeks = max(100, total_price_kopeks) price_per_device_kopeks = total_price_kopeks // devices if devices > 0 else 0 @@ -3256,7 +3259,7 @@ async def update_countries( else: discounted_per_month = server_price_per_month - charged_price, charged_months = calculate_prorated_price( + charged_price, charged_days = calculate_prorated_price( discounted_per_month, user.subscription.end_date, ) @@ -4668,7 +4671,7 @@ async def switch_traffic_package( price_diff = int(price_diff * (100 - traffic_discount_percent) / 100) # Prorated calculation - final_price, months_charged = calculate_prorated_price(price_diff, user.subscription.end_date) + final_price, days_charged = calculate_prorated_price(price_diff, user.subscription.end_date) if user.balance_kopeks < final_price: raise HTTPException( diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py index 43cab991..e408f6e4 100644 --- a/app/database/crud/campaign.py +++ b/app/database/crud/campaign.py @@ -5,6 +5,7 @@ from sqlalchemy import and_, delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.database.crud.transaction import REAL_PAYMENT_METHODS from app.database.models import ( AdvertisingCampaign, AdvertisingCampaignRegistration, @@ -268,11 +269,13 @@ async def get_campaign_statistics( ) subscription_bonuses_issued = subscription_count_result.scalar() or 0 + # Only count real deposits (exclude promo bonuses, wheel prizes, admin top-ups) deposits_result = await db.execute( select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( Transaction.user_id.in_(select(registrations_subquery.c.user_id)), Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed.is_(True), + Transaction.payment_method.in_(REAL_PAYMENT_METHODS), ) ) deposits_total = deposits_result.scalar() or 0 diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index d50eddec..23d5dc5b 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -21,7 +21,7 @@ from app.database.models import ( UserPromoGroup, UserStatus, ) -from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months +from app.utils.pricing_utils import calculate_months_from_days from app.utils.timezone import format_local_datetime @@ -1114,7 +1114,8 @@ async def add_subscription_servers( await db.refresh(subscription) if paid_prices is None: - months_remaining = get_remaining_months(subscription.end_date) + now = datetime.now(UTC) + days_remaining = max(1, (subscription.end_date - now).days) paid_prices = [] from app.database.models import ServerSquad @@ -1122,7 +1123,7 @@ async def add_subscription_servers( for server_id in server_squad_ids: result = await db.execute(select(ServerSquad.price_kopeks).where(ServerSquad.id == server_id)) server_price_per_month = result.scalar() or 0 - total_price_for_period = server_price_per_month * months_remaining + total_price_for_period = int(server_price_per_month * days_remaining / 30) paid_prices.append(total_price_for_period) for i, server_id in enumerate(server_squad_ids): @@ -1556,8 +1557,9 @@ async def calculate_addon_cost_for_remaining_period( if additional_server_ids is None: additional_server_ids = [] - months_to_pay = get_remaining_months(subscription.end_date) - period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + now = datetime.now(UTC) + days_to_pay = max(1, (subscription.end_date - now).days) + period_hint_days = days_to_pay total_cost = 0 @@ -1575,11 +1577,11 @@ async def calculate_addon_cost_for_remaining_period( ) 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 = discounted_traffic_per_month * months_to_pay + 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}₽/мес × {months_to_pay} = {traffic_total_cost / 100}₽' + 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}%: -{traffic_discount_per_month * months_to_pay / 100}₽)' + message += f' (скидка {traffic_discount_percent}%: -{int(traffic_discount_per_month * days_to_pay / 30) / 100}₽)' logger.info(message) if additional_devices > 0: @@ -1592,11 +1594,11 @@ async def calculate_addon_cost_for_remaining_period( ) 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 = discounted_devices_per_month * months_to_pay + 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}₽/мес × {months_to_pay} = {devices_total_cost / 100}₽' + 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}%: -{devices_discount_per_month * months_to_pay / 100}₽)' + message += f' (скидка {devices_discount_percent}%: -{int(devices_discount_per_month * days_to_pay / 30) / 100}₽)' logger.info(message) if additional_server_ids: @@ -1617,16 +1619,16 @@ async def calculate_addon_cost_for_remaining_period( ) 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 = discounted_server_per_month * months_to_pay + 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}₽/мес × {months_to_pay} = {server_total_cost / 100}₽' + 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}%: -{server_discount_per_month * months_to_pay / 100}₽)' + f' (скидка {servers_discount_percent}%: -{int(server_discount_per_month * days_to_pay / 30) / 100}₽)' ) logger.info(message) - logger.info('💰 Итого доплата за мес: ₽', months_to_pay=months_to_pay, total_cost=total_cost / 100) + logger.info('💰 Итого доплата за дн.: ₽', days_to_pay=days_to_pay, total_cost=total_cost / 100) return total_cost diff --git a/app/handlers/subscription/common.py b/app/handlers/subscription/common.py index 2e864f0c..304263f6 100644 --- a/app/handlers/subscription/common.py +++ b/app/handlers/subscription/common.py @@ -3,7 +3,7 @@ import base64 import html as html_mod import re import time -from datetime import datetime +from datetime import UTC, datetime from typing import Any from urllib.parse import quote @@ -15,7 +15,6 @@ from app.database.models import Subscription, User from app.localization.texts import get_texts from app.utils.pricing_utils import ( apply_percentage_discount, - get_remaining_months, ) from app.utils.promo_offer import ( get_user_active_promo_discount_percent, @@ -109,14 +108,15 @@ def _apply_promo_offer_discount(user: User | None, amount: int) -> dict[str, int def _get_period_hint_from_subscription(subscription: Subscription | None) -> int | None: - if not subscription: + if not subscription or not subscription.end_date: return None - months_remaining = get_remaining_months(subscription.end_date) - if months_remaining <= 0: + now = datetime.now(UTC) + days_remaining = (subscription.end_date - now).days + if days_remaining <= 0: return None - return months_remaining * 30 + return days_remaining def _apply_discount_to_monthly_component( @@ -518,12 +518,15 @@ def get_traffic_switch_keyboard( if base_traffic_gb is None: base_traffic_gb = current_traffic_gb - months_multiplier = 1 - period_text = '' + # Считаем по дням (как в кабинете и подтверждении) if subscription_end_date: - months_multiplier = get_remaining_months(subscription_end_date) - if months_multiplier > 1: - period_text = f' (за {months_multiplier} мес)' + now = datetime.now(UTC) + days_left = max(1, (subscription_end_date - now).days) + price_multiplier = days_left / 30 + period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)' + else: + price_multiplier = 1 + period_text = '' packages = settings.get_traffic_packages() enabled_packages = [pkg for pkg in packages if pkg['enabled']] @@ -546,7 +549,7 @@ def get_traffic_switch_keyboard( ) price_diff_per_month = discounted_price_per_month - discounted_current_per_month - total_price_diff = price_diff_per_month * months_multiplier + total_price_diff = int(price_diff_per_month * price_multiplier) # Сравниваем с базовым трафиком (без докупленного) if gb == base_traffic_gb: @@ -558,7 +561,7 @@ def get_traffic_switch_keyboard( action_text = '' price_text = f' (+{total_price_diff // 100}₽{period_text})' if discount_percent > 0: - discount_total = (price_per_month - current_price_per_month) * months_multiplier - total_price_diff + discount_total = int((price_per_month - current_price_per_month) * price_multiplier) - total_price_diff if discount_total > 0: price_text += f' (скидка {discount_percent}%: -{discount_total // 100}₽)' elif total_price_diff < 0: diff --git a/app/handlers/subscription/countries.py b/app/handlers/subscription/countries.py index 92244ff1..30b8c737 100644 --- a/app/handlers/subscription/countries.py +++ b/app/handlers/subscription/countries.py @@ -25,7 +25,6 @@ from app.states import SubscriptionStates from app.utils.pricing_utils import ( apply_percentage_discount, calculate_prorated_price, - get_remaining_months, ) from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger @@ -253,9 +252,10 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User, logger.info('🔧 Добавлено: Удалено', added=added, removed=removed) - months_to_pay = get_remaining_months(subscription.end_date) + now = datetime.now(UTC) + days_to_pay = max(1, (subscription.end_date - now).days) - period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + period_hint_days = days_to_pay if days_to_pay > 0 else None servers_discount_percent = _get_addon_discount_percent_for_user( db_user, 'servers', @@ -290,24 +290,24 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User, if country['uuid'] in removed: removed_names.append(country['name']) - total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date) + total_cost, charged_days = calculate_prorated_price(cost_per_month, subscription.end_date) - added_server_prices = [component['discounted_per_month'] * charged_months for component in added_server_components] + added_server_prices = [int(component['discounted_per_month'] * charged_days / 30) for component in added_server_components] - total_discount = sum(component['discount_per_month'] * charged_months for component in added_server_components) + total_discount = sum(int(component['discount_per_month'] * charged_days / 30) for component in added_server_components) if added_names: logger.info( - 'Стоимость новых серверов: ₽/мес × мес = ₽ (скидка ₽)', + 'Стоимость новых серверов: ₽/мес × дн./30 = ₽ (скидка ₽)', cost_per_month=cost_per_month / 100, - charged_months=charged_months, + charged_days=charged_days, total_cost=total_cost / 100, total_discount=total_discount / 100, ) if total_cost > 0 and db_user.balance_kopeks < total_cost: missing_kopeks = total_cost - db_user.balance_kopeks - required_text = f'{texts.format_price(total_cost)} (за {charged_months} мес)' + required_text = f'{texts.format_price(total_cost)} (за {charged_days} дн.)' message_text = texts.t( 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', ( @@ -349,7 +349,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User, try: if added and total_cost > 0: success = await subtract_user_balance( - db, db_user, total_cost, f'Добавление стран: {", ".join(added_names)} на {charged_months} мес' + db, db_user, total_cost, f'Добавление стран: {", ".join(added_names)} за {charged_days} дн.' ) if not success: await callback.answer( @@ -363,7 +363,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User, user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=total_cost, - description=f'Добавление стран к подписке: {", ".join(added_names)} на {charged_months} мес', + description=f'Добавление стран к подписке: {", ".join(added_names)} за {charged_days} дн.', ) if added: @@ -377,8 +377,8 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User, await add_user_to_servers(db, added_server_ids) logger.info( - '📊 Добавлены серверы с ценами за мес', - charged_months=charged_months, + '📊 Добавлены серверы с ценами за дн.', + charged_days=charged_days, value=list(zip(added_server_ids, added_server_prices, strict=False)), ) @@ -415,10 +415,10 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User, if total_cost > 0: success_text += '\n' + texts.t( 'COUNTRY_CHANGES_CHARGED', - '💰 Списано: {amount} (за {months} мес)', + '💰 Списано: {amount} (за {days} дн.)', ).format( amount=texts.format_price(total_cost), - months=charged_months, + days=charged_days, ) if total_discount > 0: success_text += texts.t( @@ -830,13 +830,13 @@ async def confirm_add_countries_to_subscription( discounted_per_month = server_price discount_per_month = 0 - charged_price, charged_months = calculate_prorated_price( + charged_price, charged_days = calculate_prorated_price( discounted_per_month, subscription.end_date, ) total_price += charged_price - total_discount_value += discount_per_month * charged_months + total_discount_value += int(discount_per_month * charged_days / 30) new_countries_names.append(country['name']) if country['uuid'] in removed_countries: removed_countries_names.append(country['name']) diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index c9a7e921..ae1a2d09 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -27,7 +27,6 @@ from app.services.user_cart_service import user_cart_service from app.utils.pagination import paginate_list from app.utils.pricing_utils import ( apply_percentage_discount, - get_remaining_months, ) from app.utils.subscription_utils import ( get_display_subscription_link, @@ -551,13 +550,13 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d ) return - charged_months = get_remaining_months(subscription.end_date) + charged_days = max(1, (subscription.end_date - datetime.now(UTC)).days) await create_transaction( db=db, user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price, - description=f'Изменение устройств с {current_devices} до {new_devices_count} на {charged_months} мес', + description=f'Изменение устройств с {current_devices} до {new_devices_count} за {charged_days} дн.', ) # Re-lock subscription after subtract_user_balance committed (released all locks) diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py index ccaf14b2..86f6676a 100644 --- a/app/handlers/subscription/traffic.py +++ b/app/handlers/subscription/traffic.py @@ -26,7 +26,6 @@ from app.states import SubscriptionStates from app.utils.pricing_utils import ( apply_percentage_discount, calculate_prorated_price, - get_remaining_months, ) from .common import ( @@ -482,7 +481,7 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes discounted_per_month = discount_result['discounted'] discount_per_month = discount_result['discount'] - charged_months = 1 + charged_days = 30 # На тарифах пакеты трафика покупаются на 1 месяц (30 дней), # цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки. @@ -492,14 +491,14 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes if is_tariff_mode: price = discounted_per_month elif subscription: - price, charged_months = calculate_prorated_price( + price, charged_days = calculate_prorated_price( discounted_per_month, subscription.end_date, ) else: price = discounted_per_month - total_discount_value = discount_per_month * charged_months + total_discount_value = int(discount_per_month * charged_days / 30) if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks @@ -716,8 +715,9 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d old_price_per_month = settings.get_traffic_price(base_traffic) new_price_per_month = settings.get_traffic_price(new_traffic_gb) - months_remaining = get_remaining_months(subscription.end_date) - period_hint_days = months_remaining * 30 if months_remaining > 0 else None + now = datetime.now(UTC) + days_remaining = max(1, (subscription.end_date - now).days) + period_hint_days = days_remaining if days_remaining > 0 else None traffic_discount_percent = _get_addon_discount_percent_for_user( db_user, 'traffic', @@ -736,7 +736,8 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d discount_savings_per_month = (new_price_per_month - old_price_per_month) - price_difference_per_month if price_difference_per_month > 0: - total_price_difference = price_difference_per_month * months_remaining + total_price_difference = int(price_difference_per_month * days_remaining / 30) + total_price_difference = max(100, total_price_difference) if db_user.balance_kopeks < total_price_difference: missing_kopeks = total_price_difference - db_user.balance_kopeks @@ -750,7 +751,7 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d 'Выберите способ пополнения. Сумма подставится автоматически.' ), ).format( - required=f'{texts.format_price(total_price_difference)} (за {months_remaining} мес)', + required=f'{texts.format_price(total_price_difference)} (за {days_remaining} дн.)', balance=texts.format_price(db_user.balance_kopeks), missing=texts.format_price(missing_kopeks), ) @@ -767,9 +768,9 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d return action_text = f'увеличить до {texts.format_traffic(new_traffic_gb)}' - cost_text = f'Доплата: {texts.format_price(total_price_difference)} (за {months_remaining} мес)' + cost_text = f'Доплата: {texts.format_price(total_price_difference)} (за {days_remaining} дн.)' if discount_savings_per_month > 0: - total_discount_savings = discount_savings_per_month * months_remaining + total_discount_savings = int(discount_savings_per_month * days_remaining / 30) cost_text += f' (скидка {traffic_discount_percent}%: -{texts.format_price(total_discount_savings)})' else: total_price_difference = 0 @@ -811,13 +812,13 @@ async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, d await callback.answer('⚠️ Ошибка списания средств', show_alert=True) return - months_remaining = get_remaining_months(subscription.end_date) + days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days) await create_transaction( db=db, user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price_difference, - description=f'Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB на {months_remaining} мес', + description=f'Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB за {days_remaining} дн.', ) subscription.traffic_limit_gb = new_traffic_gb diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index db31275e..7be72f2b 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1957,18 +1957,19 @@ def get_add_traffic_keyboard( discount_percent: int = 0, ) -> InlineKeyboardMarkup: from app.config import settings - from app.utils.pricing_utils import get_remaining_months - texts = get_texts(language) language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower() use_russian_fallback = language_code in {'ru', 'fa'} - months_multiplier = 1 - period_text = '' + # Считаем по дням (как в кабинете и подтверждении) if subscription_end_date: - months_multiplier = get_remaining_months(subscription_end_date) - if months_multiplier > 1: - period_text = f' (за {months_multiplier} мес)' + now = datetime.now(UTC) + days_left = max(1, (subscription_end_date - now).days) + price_multiplier = days_left / 30 + period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)' + else: + price_multiplier = 1 + period_text = '' packages = settings.get_traffic_topup_packages() enabled_packages = [pkg for pkg in packages if pkg['enabled'] and pkg['price'] > 0] @@ -1995,8 +1996,9 @@ def get_add_traffic_keyboard( price_per_month, discount_percent, ) - total_price = discounted_per_month * months_multiplier - total_discount = discount_per_month * months_multiplier + total_price = int(discounted_per_month * price_multiplier) + total_price = max(100, total_price) if total_price > 0 else 0 + total_discount = int(discount_per_month * price_multiplier) if gb == 0: if use_russian_fallback: @@ -2094,30 +2096,18 @@ def get_change_devices_keyboard( tariff=None, # Тариф для цены за устройство ) -> InlineKeyboardMarkup: from app.config import settings - from app.utils.pricing_utils import get_remaining_months texts = get_texts(language) - # Проверяем является ли тариф суточным - is_daily_tariff = tariff and getattr(tariff, 'is_daily', False) - - # Для суточных тарифов считаем по дням, для обычных - по месяцам - if is_daily_tariff and subscription_end_date: - # Суточный тариф: цена за оставшиеся дни (обычно 1 день) + # Считаем по дням (как в кабинете и подтверждении) + if subscription_end_date: now = datetime.now(UTC) days_left = max(1, (subscription_end_date - now).days) - # Множитель = days_left / 30 (как в кабинете) price_multiplier = days_left / 30 period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)' else: - # Обычный тариф: цена за оставшиеся месяцы - months_multiplier = 1 + price_multiplier = 1 period_text = '' - if subscription_end_date: - months_multiplier = get_remaining_months(subscription_end_date) - if months_multiplier > 1: - period_text = f' (за {months_multiplier} мес)' - price_multiplier = months_multiplier # Используем цену из тарифа если есть, иначе глобальную настройку tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None @@ -2266,18 +2256,21 @@ def get_manage_countries_keyboard( subscription_end_date: datetime = None, discount_percent: int = 0, ) -> InlineKeyboardMarkup: - from app.utils.pricing_utils import get_remaining_months - texts = get_texts(language) - months_multiplier = 1 + # Считаем по дням (как в кабинете и подтверждении) if subscription_end_date: - months_multiplier = get_remaining_months(subscription_end_date) + now = datetime.now(UTC) + days_left = max(1, (subscription_end_date - now).days) + price_multiplier = days_left / 30 logger.info( - '🔍 Расчет для управления странами: осталось месяцев до', - months_multiplier=months_multiplier, + '🔍 Расчет для управления странами: осталось дней до', + days_left=days_left, subscription_end_date=subscription_end_date, ) + else: + price_multiplier = 1 + days_left = 30 buttons = [] total_cost = 0 @@ -2302,26 +2295,28 @@ def get_manage_countries_keyboard( icon = '➖' elif uuid in selected: icon = '➕' - total_cost += discounted_per_month * months_multiplier + total_cost += int(discounted_per_month * price_multiplier) else: icon = '⚪' if uuid not in current_subscription_countries and uuid in selected: - total_price = discounted_per_month * months_multiplier - if months_multiplier > 1: - price_text = f' ({discounted_per_month // 100}₽/мес × {months_multiplier} = {total_price // 100}₽)' + total_price = int(discounted_per_month * price_multiplier) + total_price = max(100, total_price) if total_price > 0 else 0 + if days_left > 30: + price_text = f' ({discounted_per_month // 100}₽/мес × {days_left} дн. = {total_price // 100}₽)' logger.info( - '🔍 Сервер : ₽/мес × мес = ₽ (скидка ₽)', + '🔍 Сервер : ₽/мес × дн./30 = ₽ (скидка ₽)', name=name, discounted_per_month=discounted_per_month / 100, - months_multiplier=months_multiplier, + days_left=days_left, total_price=total_price / 100, - discount_per_month=(discount_per_month * months_multiplier) / 100, + discount_per_month=int(discount_per_month * price_multiplier) / 100, ) else: price_text = f' ({total_price // 100}₽)' - if discount_percent > 0 and discount_per_month * months_multiplier > 0: - price_text += f' (скидка {discount_percent}%: -{(discount_per_month * months_multiplier) // 100}₽)' + total_discount_for_server = int(discount_per_month * price_multiplier) + if discount_percent > 0 and total_discount_for_server > 0: + price_text += f' (скидка {discount_percent}%: -{total_discount_for_server // 100}₽)' display_name = f'{icon} {name}{price_text}' else: display_name = f'{icon} {name}' diff --git a/app/services/partner_stats_service.py b/app/services/partner_stats_service.py index 22a50851..d18416fd 100644 --- a/app/services/partner_stats_service.py +++ b/app/services/partner_stats_service.py @@ -9,6 +9,7 @@ import structlog from sqlalchemy import and_, case, desc, func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.database.crud.transaction import REAL_PAYMENT_METHODS from app.database.models import ( AdvertisingCampaignRegistration, ReferralEarning, @@ -938,11 +939,18 @@ class PartnerStatsService: registrations_dict = {str(row.date): int(row.count) for row in registrations_by_day.all()} # --- Daily revenue (DAILY_STATS_DAYS days) --- - # Revenue = deposits (positive) + abs(subscription_payments) (stored negative) + # Revenue = real deposits (positive) + abs(subscription_payments) (stored negative) + # Exclude promo/bonus deposits (payment_method IS NULL) from revenue revenue_amount_expr = func.coalesce( func.sum( case( - (Transaction.type == TransactionType.DEPOSIT.value, Transaction.amount_kopeks), + ( + and_( + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.payment_method.in_(REAL_PAYMENT_METHODS), + ), + Transaction.amount_kopeks, + ), ( Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, func.abs(Transaction.amount_kopeks), @@ -1070,7 +1078,13 @@ class PartnerStatsService: func.coalesce( func.sum( case( - (Transaction.type == TransactionType.DEPOSIT.value, Transaction.amount_kopeks), + ( + and_( + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.payment_method.in_(REAL_PAYMENT_METHODS), + ), + Transaction.amount_kopeks, + ), else_=0, ) ), @@ -1117,7 +1131,13 @@ class PartnerStatsService: func.coalesce( func.sum( case( - (Transaction.type == TransactionType.DEPOSIT.value, Transaction.amount_kopeks), + ( + and_( + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.payment_method.in_(REAL_PAYMENT_METHODS), + ), + Transaction.amount_kopeks, + ), ( Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, func.abs(Transaction.amount_kopeks), @@ -1149,7 +1169,13 @@ class PartnerStatsService: func.coalesce( func.sum( case( - (Transaction.type == TransactionType.DEPOSIT.value, Transaction.amount_kopeks), + ( + and_( + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.payment_method.in_(REAL_PAYMENT_METHODS), + ), + Transaction.amount_kopeks, + ), ( Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, func.abs(Transaction.amount_kopeks), diff --git a/app/services/referral_withdrawal_service.py b/app/services/referral_withdrawal_service.py index a2344774..adcea99b 100644 --- a/app/services/referral_withdrawal_service.py +++ b/app/services/referral_withdrawal_service.py @@ -278,7 +278,8 @@ class ReferralWithdrawalService: if referral_ids: month_ago = datetime.now(UTC) - timedelta(days=30) - # Одним запросом получаем статистику пополнений всех рефералов за месяц + # Одним запросом получаем статистику реальных пополнений всех рефералов за месяц + # (исключаем промо-бонусы с payment_method=NULL) ref_deposits_result = await db.execute( select( Transaction.user_id, @@ -290,6 +291,7 @@ class ReferralWithdrawalService: Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, Transaction.created_at >= month_ago, + Transaction.payment_method.isnot(None), ) .group_by(Transaction.user_id) ) @@ -328,7 +330,7 @@ class ReferralWithdrawalService: if suspicious_referrals: analysis['flags'].append(f'⚠️ Подозрительная активность у {len(suspicious_referrals)} реферала(ов)') - # Общая статистика по рефералам (за всё время) + # Общая статистика по рефералам (за всё время, только реальные платежи) all_ref_deposits = await db.execute( select( func.count(func.distinct(Transaction.user_id)).label('paying_count'), @@ -338,6 +340,7 @@ class ReferralWithdrawalService: Transaction.user_id.in_(referral_ids), Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, + Transaction.payment_method.isnot(None), ) ) ref_stats = all_ref_deposits.fetchone() diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index ec03d5d4..7a405e9e 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -14,7 +14,6 @@ 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, - get_remaining_months, ) from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, @@ -1406,8 +1405,9 @@ class SubscriptionService: if additional_server_ids is None: additional_server_ids = [] - months_to_pay = get_remaining_months(subscription.end_date) - period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + 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 @@ -1424,15 +1424,15 @@ class SubscriptionService: ) 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 = discounted_traffic_per_month * months_to_pay + 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 {months_to_pay}' + 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}%: -{traffic_discount_per_month * months_to_pay / 100}₽)' + f' (скидка {traffic_discount_percent}%: -{int(traffic_discount_per_month * days_to_pay / 30) / 100}₽)' ) logger.info(message) @@ -1446,15 +1446,15 @@ class SubscriptionService: ) 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 = discounted_devices_per_month * months_to_pay + 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 {months_to_pay}' + 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}%: -{devices_discount_per_month * months_to_pay / 100}₽)' + f' (скидка {devices_discount_percent}%: -{int(devices_discount_per_month * days_to_pay / 30) / 100}₽)' ) logger.info(message) @@ -1473,20 +1473,20 @@ class SubscriptionService: ) 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 = discounted_server_per_month * months_to_pay + 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 {months_to_pay}' + 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' -{server_discount_per_month * months_to_pay / 100}₽)' + f' -{int(server_discount_per_month * days_to_pay / 30) / 100}₽)' ) logger.info(message) - logger.info('Итого доплата за мес: ₽', months_to_pay=months_to_pay, total_price=total_price / 100) + 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: diff --git a/app/utils/__init__.py b/app/utils/__init__.py index d6313d0e..4e0f7577 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -2,7 +2,6 @@ from .pricing_utils import ( calculate_months_from_days, calculate_prorated_price, format_period_description, - get_remaining_months, ) @@ -10,5 +9,4 @@ __all__ = [ 'calculate_months_from_days', 'calculate_prorated_price', 'format_period_description', - 'get_remaining_months', ] diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index 8818ad2b..e6d8776b 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -18,14 +18,6 @@ def calculate_months_from_days(days: int) -> int: return max(1, round(days / 30)) -def get_remaining_months(end_date: datetime) -> int: - current_time = datetime.now(UTC) - if end_date <= current_time: - return 1 - - remaining_days = (end_date - current_time).days - return max(1, round(remaining_days / 30)) - def calculate_period_multiplier(period_days: int) -> tuple[int, float]: exact_months = period_days / 30 @@ -41,20 +33,28 @@ def calculate_period_multiplier(period_days: int) -> tuple[int, float]: return months_count, exact_months -def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_months: int = 1) -> tuple[int, int]: - months_remaining = get_remaining_months(end_date) - months_to_charge = max(min_charge_months, months_remaining) +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. - total_price = monthly_price * months_to_charge + Returns: + tuple of (total_price_kopeks, days_charged) + """ + now = datetime.now(UTC) + 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) + if monthly_price > 0: + total_price = max(100, total_price) # Минимум 1 рубль logger.debug( - 'Расчет пропорциональной цены: ₽/мес × мес ₽', + 'Расчет пропорциональной цены: ₽/мес × дн./30 = ₽', monthly_price=monthly_price / 100, - months_to_charge=months_to_charge, + days_to_charge=days_to_charge, total_price=total_price / 100, ) - return total_price, months_to_charge + return total_price, days_to_charge def apply_percentage_discount(amount: int, percent: int) -> tuple[int, int]: @@ -156,7 +156,6 @@ async def compute_simple_subscription_price( period_days=period_days, ) base_discount = base_price_original * period_discount_percent // 100 - base_price_original - base_discount traffic_discount_percent = resolve_discount_percent( user, @@ -165,7 +164,6 @@ async def compute_simple_subscription_price( period_days=period_days, ) traffic_discount = traffic_price_original * traffic_discount_percent // 100 - traffic_price_original - traffic_discount devices_discount_percent = resolve_discount_percent( user, @@ -174,7 +172,6 @@ async def compute_simple_subscription_price( period_days=period_days, ) devices_discount = devices_price_original * devices_discount_percent // 100 - devices_price_original - devices_discount servers_discount_percent = resolve_discount_percent( user, diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 8188bdb0..57e0b01d 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -94,7 +94,6 @@ from app.utils.pricing_utils import ( apply_percentage_discount, calculate_prorated_price, format_period_description, - get_remaining_months, ) from app.utils.promo_offer import get_user_active_promo_discount_percent from app.utils.subscription_utils import get_happ_cryptolink_redirect_link @@ -4699,14 +4698,15 @@ def _get_addon_discount_percent_for_user( def _get_period_hint_from_subscription( subscription: Subscription | None, ) -> int | None: - if not subscription: + if not subscription or not subscription.end_date: return None - months_remaining = get_remaining_months(subscription.end_date) - if months_remaining <= 0: + now = datetime.now(UTC) + days_remaining = (subscription.end_date - now).days + if days_remaining <= 0: return None - return months_remaining * 30 + return days_remaining def _validate_subscription_id( @@ -4966,7 +4966,7 @@ async def _build_subscription_settings( subscription: Subscription, ) -> MiniAppSubscriptionSettings: period_hint_days = _get_period_hint_from_subscription(subscription) - months_remaining = get_remaining_months(subscription.end_date) + months_remaining = max(1, math.ceil((period_hint_days or 0) / 30)) servers_discount = _get_addon_discount_percent_for_user( user, 'servers', @@ -5807,18 +5807,18 @@ async def update_subscription_servers_endpoint( cost_per_month = sum(int(catalog[uuid].get('discounted_per_month', 0)) for uuid in added) total_cost = 0 - charged_months = 0 + charged_days = 0 if cost_per_month > 0: - total_cost, charged_months = calculate_prorated_price( + total_cost, charged_days = calculate_prorated_price( cost_per_month, subscription.end_date, ) else: - charged_months = get_remaining_months(subscription.end_date) + charged_days = max(1, (subscription.end_date - datetime.now(UTC)).days) added_server_ids = [catalog[uuid].get('server_id') for uuid in added if catalog[uuid].get('server_id') is not None] added_server_prices = [ - int(catalog[uuid].get('discounted_per_month', 0)) * charged_months + int(int(catalog[uuid].get('discounted_per_month', 0)) * charged_days / 30) for uuid in added if catalog[uuid].get('server_id') is not None ] @@ -5836,7 +5836,7 @@ async def update_subscription_servers_endpoint( if total_cost > 0: added_names = [catalog[uuid].get('name', uuid) for uuid in added] description = ( - f'Добавление серверов: {", ".join(added_names)} на {charged_months} мес' + f'Добавление серверов: {", ".join(added_names)} за {charged_days} дн.' if added_names else 'Изменение списка серверов' ) @@ -5991,8 +5991,8 @@ async def update_subscription_traffic_endpoint( }, ) - months_remaining = get_remaining_months(subscription.end_date) - period_hint_days = months_remaining * 30 if months_remaining > 0 else None + days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days) + period_hint_days = days_remaining traffic_discount = _get_addon_discount_percent_for_user( user, 'traffic', @@ -6015,7 +6015,7 @@ async def update_subscription_traffic_endpoint( total_price_difference = 0 if price_difference_per_month > 0: - total_price_difference = price_difference_per_month * months_remaining + total_price_difference = max(100, int(price_difference_per_month * days_remaining / 30)) if getattr(user, 'balance_kopeks', 0) < total_price_difference: missing = total_price_difference - getattr(user, 'balance_kopeks', 0) raise HTTPException( @@ -6048,7 +6048,7 @@ async def update_subscription_traffic_endpoint( user_id=user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=total_price_difference, - description=f'{description} на {months_remaining} мес', + description=f'{description} за {days_remaining} дн.', ) subscription.traffic_limit_gb = new_traffic @@ -6165,7 +6165,7 @@ async def update_subscription_devices_endpoint( devices_difference = new_devices - current_devices price_to_charge = 0 - charged_months = 0 + charged_days = 0 if devices_difference > 0: current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT) @@ -6173,8 +6173,8 @@ async def update_subscription_devices_endpoint( chargeable_diff = new_chargeable - current_chargeable price_per_month = chargeable_diff * tariff_device_price - months_remaining = get_remaining_months(subscription.end_date) - period_hint_days = months_remaining * 30 if months_remaining > 0 else None + days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days) + period_hint_days = days_remaining devices_discount = _get_addon_discount_percent_for_user( user, 'devices', @@ -6185,7 +6185,7 @@ async def update_subscription_devices_endpoint( price_per_month, devices_discount, ) - price_to_charge, charged_months = calculate_prorated_price( + price_to_charge, charged_days = calculate_prorated_price( discounted_per_month, subscription.end_date, ) @@ -6222,7 +6222,7 @@ async def update_subscription_devices_endpoint( user_id=user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price_to_charge, - description=f'{description} на {charged_months or get_remaining_months(subscription.end_date)} мес', + description=f'{description} за {charged_days or max(1, (subscription.end_date - datetime.now(UTC)).days)} дн.', ) if price_to_charge > 0: @@ -7267,7 +7267,7 @@ async def purchase_traffic_topup_endpoint( base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100) # Пропорциональный расчет цены с учетом оставшегося времени подписки - final_price, months_charged = calculate_prorated_price( + final_price, days_charged = calculate_prorated_price( base_price_kopeks, subscription.end_date, ) diff --git a/app/webapi/routes/stats.py b/app/webapi/routes/stats.py index 78e7cca5..e105d843 100644 --- a/app/webapi/routes/stats.py +++ b/app/webapi/routes/stats.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database.crud.referral import get_referral_statistics from app.database.crud.subscription import get_subscriptions_statistics, get_trial_statistics -from app.database.crud.transaction import get_transactions_statistics +from app.database.crud.transaction import REAL_PAYMENT_METHODS, get_transactions_statistics from app.database.crud.user import get_users_statistics from app.database.models import ( Subscription, @@ -79,6 +79,7 @@ async def _get_overview(db: AsyncSession) -> dict[str, object]: select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( func.date(Transaction.created_at) == today, Transaction.type == TransactionType.DEPOSIT.value, + Transaction.payment_method.in_(REAL_PAYMENT_METHODS), ) ) or 0