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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}'
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user