From aa1d3289e1bb2195a11c333867ac131c5460f0cc Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 5 Feb 2026 06:26:17 +0300 Subject: [PATCH] fix(cabinet): apply promo group discounts to device/traffic purchase and tariff switch - Add discount calculation for purchase_devices and get_device_price endpoints - Fix traffic purchase discount to use period-aware calculation - Apply period discount to tariff switch upgrade_cost - Return discount info in API responses for frontend display --- app/cabinet/routes/subscription.py | 316 ++++++++++++++++++++++++----- 1 file changed, 269 insertions(+), 47 deletions(-) diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index 612dd1bf..d8f65811 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -63,6 +63,71 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix='/subscription', tags=['Cabinet Subscription']) +def _get_addon_discount_percent( + user: User, + category: str, + period_days: int | None = None, +) -> int: + """Get addon discount percent for user from promo group. + + Mirrors logic from app/handlers/subscription/common.py:_get_addon_discount_percent_for_user + """ + promo_group = ( + user.get_primary_promo_group() + if hasattr(user, 'get_primary_promo_group') + else getattr(user, 'promo_group', None) + ) + if promo_group is None: + return 0 + + if not getattr(promo_group, 'apply_discounts_to_addons', True): + return 0 + + try: + return user.get_promo_discount(category, period_days) + except AttributeError: + return 0 + + +def _apply_addon_discount( + user: User, + category: str, + amount: int, + period_days: int | None = None, +) -> dict[str, int]: + """Apply addon discount to amount. + + Returns dict with keys: discounted, discount, percent + """ + 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 + return { + 'discounted': discounted_amount, + 'discount': discount_value, + 'percent': percent, + } + + +def _get_period_discount_percent(user: User, period_days: int | None = None) -> int: + """Get period discount percent for tariff switch calculations.""" + promo_group = ( + user.get_primary_promo_group() + if hasattr(user, 'get_primary_promo_group') + else getattr(user, 'promo_group', None) + ) + if promo_group is None: + return 0 + + try: + return user.get_promo_discount('period', period_days) + except AttributeError: + return 0 + + def _subscription_to_response( subscription: Subscription, servers: list[ServerInfo] | None = None, @@ -668,33 +733,29 @@ async def purchase_traffic( ) base_price_kopeks = matching_pkg['price'] - # Применяем скидку промогруппы - 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))) - - if traffic_discount_percent > 0: - base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100) - # На тарифах пакеты трафика покупаются на 1 месяц (30 дней), # цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки. # Пропорциональный расчёт применяем только в классическом режиме. if is_tariff_mode: - final_price = base_price_kopeks + prorated_price = base_price_kopeks months_charged = 1 else: - final_price, months_charged = calculate_prorated_price( + prorated_price, months_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 + discount_result = _apply_addon_discount(user, 'traffic', prorated_price, period_hint_days) + final_price = discount_result['discounted'] + traffic_discount_percent = discount_result['percent'] + discount_value = discount_result['discount'] + + # Ensure minimum price after discount (except for 100% discount) + if traffic_discount_percent < 100 and final_price > 0: + final_price = max(100, final_price) + # Проверяем баланс if user.balance_kopeks < final_price: missing = final_price - user.balance_kopeks @@ -705,7 +766,7 @@ async def purchase_traffic( 'subscription_id': subscription.id, 'traffic_gb': request.gb, 'price_kopeks': final_price, - 'base_price_kopeks': base_price_kopeks, + 'base_price_kopeks': prorated_price, 'discount_percent': traffic_discount_percent, 'source': 'cabinet', 'description': f'Докупка {request.gb} ГБ трафика', @@ -713,7 +774,10 @@ async def purchase_traffic( try: await user_cart_service.save_user_cart(user.id, cart_data) - logger.info(f'Cart saved for traffic purchase (cabinet) user {user.id}: +{request.gb} GB') + logger.info( + f'Cart saved for traffic purchase (cabinet) user {user.id}: ' + f'+{request.gb} GB, discount {traffic_discount_percent}%' + ) except Exception as e: logger.error(f'Error saving cart for traffic purchase (cabinet): {e}') @@ -806,24 +870,33 @@ async def purchase_traffic( except Exception as e: logger.error(f'Failed to send admin notification for traffic purchase: {e}') - return { + response = { 'success': True, 'message': 'Traffic purchased successfully', 'gb_added': request.gb, 'new_traffic_limit_gb': subscription.traffic_limit_gb, 'amount_paid_kopeks': final_price, - 'discount_percent': traffic_discount_percent, 'new_balance_kopeks': user.balance_kopeks, } + if traffic_discount_percent > 0: + response['discount_percent'] = traffic_discount_percent + response['discount_kopeks'] = discount_value + response['base_price_kopeks'] = prorated_price + + return response + @router.post('/devices') -async def purchase_devices( +async def purchase_devices_legacy( request: DevicePurchaseRequest, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): - """Purchase additional device slots.""" + """Purchase additional device slots (legacy endpoint without tariff support). + + DEPRECATED: Use /devices/purchase instead for full tariff and discount support. + """ await db.refresh(user, ['subscription']) if not user.subscription: @@ -833,7 +906,16 @@ async def purchase_devices( ) price_per_device = settings.PRICE_PER_DEVICE - total_price = price_per_device * request.devices + base_total_price = price_per_device * request.devices + + # Apply discount from promo group + discount_result = _apply_addon_discount(user, 'devices', base_total_price, 30) + total_price = discount_result['discounted'] + devices_discount_percent = discount_result['percent'] + + # Ensure minimum price after discount (except for 100% discount) + if devices_discount_percent < 100 and total_price > 0: + total_price = max(100, total_price) # Check balance if user.balance_kopeks < total_price: @@ -845,6 +927,8 @@ async def purchase_devices( 'cart_mode': 'add_devices', 'devices_to_add': request.devices, 'price_kopeks': total_price, + 'base_price_kopeks': base_total_price, + 'discount_percent': devices_discount_percent, 'source': 'cabinet', } await user_cart_service.save_user_cart(user.id, cart_data) @@ -879,11 +963,17 @@ async def purchase_devices( from app.database.crud.user import subtract_user_balance from app.database.models import PaymentMethod + # Build description with discount info + if devices_discount_percent > 0: + description = f'Покупка {request.devices} доп. устройств (скидка {devices_discount_percent}%)' + else: + description = f'Покупка {request.devices} доп. устройств' + await subtract_user_balance( db=db, user=user, amount_kopeks=total_price, - description=f'Покупка {request.devices} доп. устройств', + description=description, create_transaction=True, payment_method=PaymentMethod.BALANCE, ) @@ -918,13 +1008,20 @@ async def purchase_devices( except Exception as e: logger.error(f'Failed to send admin notification for device purchase: {e}') - return { + response = { 'message': 'Devices added successfully', 'devices_added': request.devices, 'new_device_limit': new_devices, 'amount_paid_kopeks': total_price, } + if devices_discount_percent > 0: + response['discount_percent'] = devices_discount_percent + response['discount_kopeks'] = discount_result['discount'] + response['base_price_kopeks'] = base_total_price + + return response + @router.patch('/autopay') async def update_autopay( @@ -2065,9 +2162,21 @@ async def purchase_devices( days_left = max(1, (end_date - now).days) total_days = 30 # Base period for device price calculation - # Price = device_price * devices * (days_left / 30) - price_kopeks = int(device_price * request.devices * days_left / total_days) - price_kopeks = max(100, price_kopeks) # Minimum 1 ruble + # Calculate base price before discount + base_price_per_month = device_price * request.devices + base_price_prorated = int(base_price_per_month * days_left / total_days) + base_price_prorated = max(100, base_price_prorated) # Minimum 1 ruble + + # Apply discount from promo group + period_hint_days = days_left + discount_result = _apply_addon_discount(user, 'devices', base_price_prorated, period_hint_days) + price_kopeks = discount_result['discounted'] + devices_discount_percent = discount_result['percent'] + discount_value = discount_result['discount'] + + # Ensure minimum price after discount (except for 100% discount) + if devices_discount_percent < 100: + price_kopeks = max(100, price_kopeks) # Check balance if user.balance_kopeks < price_kopeks: @@ -2079,10 +2188,15 @@ async def purchase_devices( 'cart_mode': 'add_devices', 'devices_to_add': request.devices, 'price_kopeks': price_kopeks, + 'base_price_kopeks': base_price_prorated, + 'discount_percent': devices_discount_percent, 'source': 'cabinet', } await user_cart_service.save_user_cart(user.id, cart_data) - logger.info(f'Cart saved for device purchase (cabinet) user {user.id}: +{request.devices} devices') + logger.info( + f'Cart saved for device purchase (cabinet) user {user.id}: ' + f'+{request.devices} devices, discount {devices_discount_percent}%' + ) except Exception as e: logger.error(f'Error saving cart for device purchase (cabinet): {e}') @@ -2102,11 +2216,17 @@ async def purchase_devices( from app.database.crud.user import subtract_user_balance from app.database.models import PaymentMethod + # Build description with discount info + if devices_discount_percent > 0: + description = f'Покупка {request.devices} доп. устройств (скидка {devices_discount_percent}%)' + else: + description = f'Покупка {request.devices} доп. устройств' + await subtract_user_balance( db=db, user=user, amount_kopeks=price_kopeks, - description=f'Покупка {request.devices} доп. устройств', + description=description, create_transaction=True, payment_method=PaymentMethod.BALANCE, ) @@ -2128,7 +2248,13 @@ async def purchase_devices( await db.refresh(user) - logger.info(f'User {user.id} purchased {request.devices} devices for {price_kopeks} kopeks') + if devices_discount_percent > 0: + logger.info( + f'User {user.id} purchased {request.devices} devices for {price_kopeks} kopeks ' + f'(discount {devices_discount_percent}%, saved {discount_value} kopeks)' + ) + else: + logger.info(f'User {user.id} purchased {request.devices} devices for {price_kopeks} kopeks') # Отправляем уведомление админам try: @@ -2154,7 +2280,7 @@ async def purchase_devices( except Exception as e: logger.error(f'Failed to send admin notification for device purchase: {e}') - return { + response = { 'success': True, 'message': f'Добавлено {request.devices} устройств', 'devices_added': request.devices, @@ -2165,6 +2291,13 @@ async def purchase_devices( 'balance_label': settings.format_price(user.balance_kopeks), } + if devices_discount_percent > 0: + response['discount_percent'] = devices_discount_percent + response['discount_kopeks'] = discount_value + response['base_price_kopeks'] = base_price_prorated + + return response + except HTTPException: raise except Exception as e: @@ -2435,11 +2568,24 @@ async def get_device_price( days_left = max(1, (end_date - now).days) total_days = 30 - price_per_device_kopeks = int(device_price * days_left / total_days) - price_per_device_kopeks = max(100, price_per_device_kopeks) - total_price_kopeks = price_per_device_kopeks * devices + # 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 - return { + # Apply discount from promo group + period_hint_days = days_left + discount_result = _apply_addon_discount(user, 'devices', base_total_price, period_hint_days) + total_price_kopeks = discount_result['discounted'] + devices_discount_percent = discount_result['percent'] + discount_value = discount_result['discount'] + + # Calculate per-device price after 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 + + response = { 'available': True, 'devices': devices, 'price_per_device_kopeks': price_per_device_kopeks, @@ -2453,6 +2599,14 @@ async def get_device_price( 'base_device_price_kopeks': device_price, } + # Add discount info if applicable + if devices_discount_percent > 0: + response['discount_percent'] = devices_discount_percent + response['discount_kopeks'] = discount_value + response['base_total_price_kopeks'] = base_total_price + + return response + # ============ App Config for Connection ============ @@ -3723,18 +3877,35 @@ async def preview_tariff_switch( return int(min_price * 30 / min_period) return 0 + # Get period discount percent for cost calculation + period_discount_percent = _get_period_discount_percent(user, remaining_days if remaining_days > 0 else 30) + base_upgrade_cost = 0 + discount_value = 0 + if switching_to_daily: # Switching TO daily - pay first day price daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) - upgrade_cost = daily_price - is_upgrade = daily_price > 0 + base_upgrade_cost = daily_price + # Apply discount to daily price + if period_discount_percent > 0 and base_upgrade_cost > 0: + discount_value = int(base_upgrade_cost * period_discount_percent / 100) + upgrade_cost = base_upgrade_cost - discount_value + else: + upgrade_cost = base_upgrade_cost + is_upgrade = upgrade_cost > 0 elif switching_from_daily: # Switching FROM daily TO periodic - full payment for new tariff min_period_price = 0 if new_tariff.period_prices: min_period_price = min(new_tariff.period_prices.values()) - upgrade_cost = min_period_price - is_upgrade = min_period_price > 0 + base_upgrade_cost = min_period_price + # Apply discount + if period_discount_percent > 0 and base_upgrade_cost > 0: + discount_value = int(base_upgrade_cost * period_discount_percent / 100) + upgrade_cost = base_upgrade_cost - discount_value + else: + upgrade_cost = base_upgrade_cost + is_upgrade = upgrade_cost > 0 else: # Calculate proportional cost difference using monthly prices current_monthly = get_monthly_price(current_tariff) @@ -3744,18 +3915,25 @@ async def preview_tariff_switch( if price_diff > 0: # Upgrade - pay proportional difference - upgrade_cost = int(price_diff * remaining_days / 30) + base_upgrade_cost = int(price_diff * remaining_days / 30) + # Apply discount to upgrade cost + if period_discount_percent > 0 and base_upgrade_cost > 0: + discount_value = int(base_upgrade_cost * period_discount_percent / 100) + upgrade_cost = base_upgrade_cost - discount_value + else: + upgrade_cost = base_upgrade_cost is_upgrade = True else: # Downgrade or same - free upgrade_cost = 0 + base_upgrade_cost = 0 is_upgrade = False balance = user.balance_kopeks or 0 has_enough = balance >= upgrade_cost missing = max(0, upgrade_cost - balance) if not has_enough else 0 - return { + response = { 'can_switch': has_enough, 'current_tariff_id': current_tariff.id if current_tariff else None, 'current_tariff_name': current_tariff.name if current_tariff else None, @@ -3772,6 +3950,14 @@ async def preview_tariff_switch( 'is_upgrade': is_upgrade, } + # Add discount info if applicable + if period_discount_percent > 0 and discount_value > 0: + response['discount_percent'] = period_discount_percent + response['discount_kopeks'] = discount_value + response['base_upgrade_cost_kopeks'] = base_upgrade_cost + + return response + @router.post('/tariff/switch') async def switch_tariff( @@ -3857,6 +4043,11 @@ async def switch_tariff( switching_from_daily = current_is_daily and not new_is_daily switching_to_daily = not current_is_daily and new_is_daily + # Get period discount percent for cost calculation + period_discount_percent = _get_period_discount_percent(user, remaining_days if remaining_days > 0 else 30) + base_upgrade_cost = 0 + discount_value = 0 + if switching_to_daily: # Switching TO daily tariff - charge first day price daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) @@ -3865,7 +4056,13 @@ async def switch_tariff( status_code=status.HTTP_400_BAD_REQUEST, detail='Daily tariff has invalid price', ) - upgrade_cost = daily_price + base_upgrade_cost = daily_price + # Apply discount + if period_discount_percent > 0 and base_upgrade_cost > 0: + discount_value = int(base_upgrade_cost * period_discount_percent / 100) + upgrade_cost = base_upgrade_cost - discount_value + else: + upgrade_cost = base_upgrade_cost new_period_days = 1 # Daily tariff starts with 1 day elif switching_from_daily: # Switch FROM daily to regular tariff - pay for minimum period @@ -3874,7 +4071,13 @@ async def switch_tariff( if new_tariff.period_prices: min_period_days = min(int(k) for k in new_tariff.period_prices.keys()) min_period_price = new_tariff.period_prices.get(str(min_period_days), 0) - upgrade_cost = min_period_price + base_upgrade_cost = min_period_price + # Apply discount + if period_discount_percent > 0 and base_upgrade_cost > 0: + discount_value = int(base_upgrade_cost * period_discount_percent / 100) + upgrade_cost = base_upgrade_cost - discount_value + else: + upgrade_cost = base_upgrade_cost new_period_days = min_period_days else: # Regular tariff switch - calculate proportional cost difference using monthly prices @@ -3899,9 +4102,16 @@ async def switch_tariff( price_diff = new_monthly - current_monthly if price_diff > 0: - upgrade_cost = int(price_diff * remaining_days / 30) + base_upgrade_cost = int(price_diff * remaining_days / 30) + # Apply discount + if period_discount_percent > 0 and base_upgrade_cost > 0: + discount_value = int(base_upgrade_cost * period_discount_percent / 100) + upgrade_cost = base_upgrade_cost - discount_value + else: + upgrade_cost = base_upgrade_cost else: upgrade_cost = 0 + base_upgrade_cost = 0 new_period_days = 0 # Charge if upgrade @@ -3924,6 +4134,10 @@ async def switch_tariff( else: description = f"Переход на тариф '{new_tariff.name}' (доплата за {remaining_days} дней)" + # Add discount info to description if applicable + if period_discount_percent > 0 and discount_value > 0: + description += f' (скидка {period_discount_percent}%)' + success = await subtract_user_balance(db, user, upgrade_cost, description) if not success: raise HTTPException( @@ -4018,7 +4232,7 @@ async def switch_tariff( except Exception as e: logger.error(f'Failed to send admin notification for tariff switch: {e}') - return { + response = { 'success': True, 'message': f"Switched from '{old_tariff_name}' to '{new_tariff.name}'" + (' (devices reset)' if devices_reset else ''), @@ -4031,6 +4245,14 @@ async def switch_tariff( 'balance_label': settings.format_price(user.balance_kopeks), } + # Add discount info if applicable + if period_discount_percent > 0 and discount_value > 0: + response['discount_percent'] = period_discount_percent + response['discount_kopeks'] = discount_value + response['base_charged_kopeks'] = base_upgrade_cost + + return response + # ============ Daily Subscription Pause ============