fix: migrate pricing to days-based proration, fix promo revenue leaks, fix admin panel bugs

- Migrate all addon pricing (devices, traffic, countries) from months-based to days-based proration
- Remove get_remaining_months() utility, use days_left / 30 consistently
- Fix promo/campaign balance bonuses counted as revenue in reports (add REAL_PAYMENT_METHODS filter)
- Fix partner stats, campaign stats, miniapp stats, referral fraud detection promo deposit leaks
- Fix admin balance history showing deductions with + sign (use -abs for expense types)
- Fix promo offer deactivation returning 400 for non-promocode offers
- Fix daily tariff renewal requesting 30-day renewal instead of 1-day purchase
This commit is contained in:
Fringg
2026-03-10 03:30:10 +03:00
parent 834a0478ae
commit fcdeff1ee5
19 changed files with 278 additions and 226 deletions
+44 -25
View File
@@ -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,
)
+3 -1
View File
@@ -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),
)
)
)
+6 -6
View File
@@ -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,
+2 -2
View File
@@ -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(
+34 -31
View File
@@ -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(
+3
View File
@@ -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
+17 -15
View File
@@ -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
+16 -13
View File
@@ -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:
+17 -17
View File
@@ -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'])
+2 -3
View File
@@ -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)
+13 -12
View File
@@ -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
+34 -39
View File
@@ -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}'
+31 -5
View File
@@ -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),
+5 -2
View File
@@ -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()
+13 -13
View File
@@ -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:
-2
View File
@@ -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',
]
+15 -18
View File
@@ -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,
+21 -21
View File
@@ -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,
)
+2 -1
View File
@@ -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