diff --git a/app/cabinet/routes/admin_stats.py b/app/cabinet/routes/admin_stats.py index 70c1a96b..14c067ec 100644 --- a/app/cabinet/routes/admin_stats.py +++ b/app/cabinet/routes/admin_stats.py @@ -686,53 +686,6 @@ async def get_top_referrers( if row.referrer_id in referrers_data: referrers_data[row.referrer_id]['earnings_month'] = row.total or 0 - # Also add REFERRAL_REWARD transactions - trans_total_query = await db.execute( - select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total')) - .where(Transaction.type == TransactionType.REFERRAL_REWARD.value) - .group_by(Transaction.user_id) - ) - for row in trans_total_query: - if row.referrer_id in referrers_data: - referrers_data[row.referrer_id]['earnings_total'] = referrers_data[row.referrer_id].get( - 'earnings_total', 0 - ) + (row.total or 0) - - trans_today_query = await db.execute( - select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total')) - .where( - and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= today_start) - ) - .group_by(Transaction.user_id) - ) - for row in trans_today_query: - if row.referrer_id in referrers_data: - referrers_data[row.referrer_id]['earnings_today'] = referrers_data[row.referrer_id].get( - 'earnings_today', 0 - ) + (row.total or 0) - - trans_week_query = await db.execute( - select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total')) - .where(and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= week_ago)) - .group_by(Transaction.user_id) - ) - for row in trans_week_query: - if row.referrer_id in referrers_data: - referrers_data[row.referrer_id]['earnings_week'] = referrers_data[row.referrer_id].get( - 'earnings_week', 0 - ) + (row.total or 0) - - trans_month_query = await db.execute( - select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total')) - .where(and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= month_ago)) - .group_by(Transaction.user_id) - ) - for row in trans_month_query: - if row.referrer_id in referrers_data: - referrers_data[row.referrer_id]['earnings_month'] = referrers_data[row.referrer_id].get( - 'earnings_month', 0 - ) + (row.total or 0) - # Get user info for all referrers referrer_ids = list(referrers_data.keys()) if referrer_ids: diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index 8c13c742..6f942b04 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -26,6 +26,7 @@ from app.database.crud.user import ( ) from app.database.models import ( PromoGroup, + ReferralEarning, Subscription, SubscriptionServer, SubscriptionStatus, @@ -546,11 +547,9 @@ async def get_user_detail( referrals = await get_referrals(db, user.id) referrals_count = len(referrals) - # Calculate total referral earnings - referral_earnings_q = select(func.sum(Transaction.amount_kopeks)).where( - Transaction.user_id == user.id, - Transaction.type == TransactionType.REFERRAL_REWARD.value, - Transaction.is_completed == True, + # Calculate total referral earnings (canonical source: ReferralEarning) + referral_earnings_q = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where( + ReferralEarning.user_id == user.id ) referral_earnings = (await db.execute(referral_earnings_q)).scalar() or 0 diff --git a/app/cabinet/routes/referral.py b/app/cabinet/routes/referral.py index a4f373e9..4237890b 100644 --- a/app/cabinet/routes/referral.py +++ b/app/cabinet/routes/referral.py @@ -9,7 +9,15 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.config import settings -from app.database.models import AdvertisingCampaign, ReferralEarning, User, WithdrawalRequest, WithdrawalRequestStatus +from app.database.models import ( + AdvertisingCampaign, + ReferralEarning, + Subscription, + SubscriptionStatus, + User, + WithdrawalRequest, + WithdrawalRequestStatus, +) from ..dependencies import get_cabinet_db, get_current_cabinet_user from ..schemas.referral import ( @@ -38,12 +46,15 @@ async def get_referral_info( total_result = await db.execute(total_query) total_referrals = total_result.scalar() or 0 - # Get active referrals (with subscription) + # Get active referrals (with active subscription right now) active_query = ( - select(func.count()) - .select_from(User) - .where(User.referred_by_id == user.id) - .where(User.has_had_paid_subscription == True) + select(func.count(func.distinct(User.id))) + .join(Subscription, User.id == Subscription.user_id) + .where( + User.referred_by_id == user.id, + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.end_date > func.now(), + ) ) active_result = await db.execute(active_query) active_referrals = active_result.scalar() or 0 diff --git a/app/database/crud/referral.py b/app/database/crud/referral.py index fe163f30..b591a3e5 100644 --- a/app/database/crud/referral.py +++ b/app/database/crud/referral.py @@ -5,7 +5,7 @@ from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import AdvertisingCampaignRegistration, ReferralEarning, User +from app.database.models import AdvertisingCampaignRegistration, ReferralEarning, Subscription, SubscriptionStatus, User logger = structlog.get_logger(__name__) @@ -89,7 +89,7 @@ async def get_referral_earnings_sum( query = query.where(ReferralEarning.created_at <= end_date) result = await db.execute(query) - return result.scalar() + return result.scalar() or 0 async def get_referral_statistics(db: AsyncSession) -> dict: @@ -104,18 +104,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict: active_referrers = active_referrers_result.scalar() referral_paid_result = await db.execute(select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))) - referral_paid = referral_paid_result.scalar() - - from app.database.models import Transaction, TransactionType - - transaction_paid_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( - Transaction.type == TransactionType.REFERRAL_REWARD.value - ) - ) - transaction_paid = transaction_paid_result.scalar() - - total_paid = referral_paid + transaction_paid + total_paid = referral_paid_result.scalar() referrals_stats_result = await db.execute( select(User.referred_by_id.label('referrer_id'), func.count(User.id).label('referrals_count')) @@ -132,15 +121,6 @@ async def get_referral_statistics(db: AsyncSession) -> dict: ) referral_earnings = {row.referrer_id: row.referral_earnings for row in referral_earnings_result.all()} - transaction_earnings_result = await db.execute( - select( - Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('transaction_earnings') - ) - .where(Transaction.type == TransactionType.REFERRAL_REWARD.value) - .group_by(Transaction.user_id) - ) - transaction_earnings = {row.referrer_id: row.transaction_earnings for row in transaction_earnings_result.all()} - top_referrers_data = {} for referrer_id, count in referrals_stats.items(): @@ -153,11 +133,6 @@ async def get_referral_statistics(db: AsyncSession) -> dict: top_referrers_data[referrer_id] = {'referrals_count': 0, 'total_earned': 0} top_referrers_data[referrer_id]['total_earned'] += earnings or 0 - for referrer_id, earnings in transaction_earnings.items(): - if referrer_id not in top_referrers_data: - top_referrers_data[referrer_id] = {'referrals_count': 0, 'total_earned': 0} - top_referrers_data[referrer_id]['total_earned'] += earnings or 0 - sorted_referrers = sorted( top_referrers_data.items(), key=lambda x: (x[1]['total_earned'], x[1]['referrals_count']), reverse=True ) @@ -197,37 +172,22 @@ async def get_referral_statistics(db: AsyncSession) -> dict: today = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) - today_referral_earnings_result = await db.execute( + today_earnings_result = await db.execute( select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= today) ) - today_transaction_earnings_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( - and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= today) - ) - ) - today_earnings = today_referral_earnings_result.scalar() + today_transaction_earnings_result.scalar() + today_earnings = today_earnings_result.scalar() week_ago = datetime.now(UTC) - timedelta(days=7) - week_referral_earnings_result = await db.execute( + week_earnings_result = await db.execute( select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= week_ago) ) - week_transaction_earnings_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( - and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= week_ago) - ) - ) - week_earnings = week_referral_earnings_result.scalar() + week_transaction_earnings_result.scalar() + week_earnings = week_earnings_result.scalar() month_ago = datetime.now(UTC) - timedelta(days=30) - month_referral_earnings_result = await db.execute( + month_earnings_result = await db.execute( select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= month_ago) ) - month_transaction_earnings_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( - and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= month_ago) - ) - ) - month_earnings = month_referral_earnings_result.scalar() + month_transaction_earnings_result.scalar() + month_earnings = month_earnings_result.scalar() logger.info( 'Реферальная статистика: рефералов, рефереров, выплачено копеек', @@ -264,8 +224,6 @@ async def get_top_referrers_by_period( Returns: Список словарей с данными рефереров """ - from app.database.models import Transaction, TransactionType - now = datetime.now(UTC) if period == 'week': start_date = now - timedelta(days=7) @@ -292,18 +250,6 @@ async def get_top_referrers_by_period( ) earnings = earnings_result.scalar() or 0 - # Добавляем транзакции REFERRAL_REWARD - trans_earnings_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( - and_( - Transaction.user_id == row.referrer_id, - Transaction.type == TransactionType.REFERRAL_REWARD.value, - Transaction.created_at >= start_date, - ) - ) - ) - earnings += trans_earnings_result.scalar() or 0 - top_data.append( {'referrer_id': row.referrer_id, 'invited_count': row.invited_count, 'earnings_kopeks': earnings} ) @@ -320,27 +266,8 @@ async def get_top_referrers_by_period( ) referral_earnings = {row.referrer_id: row.ref_earnings for row in referral_earnings_result} - # Добавляем транзакции REFERRAL_REWARD - transaction_earnings_result = await db.execute( - select( - Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('trans_earnings') - ) - .where( - and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= start_date) - ) - .group_by(Transaction.user_id) - ) - - # Объединяем заработки - combined_earnings = dict(referral_earnings) - for row in transaction_earnings_result: - if row.referrer_id in combined_earnings: - combined_earnings[row.referrer_id] += row.trans_earnings or 0 - else: - combined_earnings[row.referrer_id] = row.trans_earnings or 0 - # Сортируем и берём топ - sorted_referrers = sorted(combined_earnings.items(), key=lambda x: x[1], reverse=True)[:limit] + sorted_referrers = sorted(referral_earnings.items(), key=lambda x: x[1], reverse=True)[:limit] top_data = [] for referrer_id, earnings in sorted_referrers: @@ -400,22 +327,18 @@ async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict: month_ago = datetime.now(UTC) - timedelta(days=30) month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago) - from app.database.models import Subscription, SubscriptionStatus - - current_time = datetime.now(UTC) - active_referrals_result = await db.execute( - select(func.count(User.id)) + select(func.count(func.distinct(User.id))) .join(Subscription, User.id == Subscription.user_id) .where( and_( User.referred_by_id == user_id, Subscription.status == SubscriptionStatus.ACTIVE.value, - Subscription.end_date > current_time, + Subscription.end_date > func.now(), ) ) ) - active_referrals = active_referrals_result.scalar() + active_referrals = active_referrals_result.scalar() or 0 return { 'invited_count': invited_count, diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 22ca3783..606914dd 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -24,6 +24,15 @@ logger = structlog.get_logger(__name__) TRANSACTIONS_PER_PAGE = 10 +CREDIT_TRANSACTION_TYPES: frozenset[str] = frozenset( + { + TransactionType.DEPOSIT.value, + TransactionType.REFERRAL_REWARD.value, + TransactionType.REFUND.value, + TransactionType.POLL_REWARD.value, + } +) + async def route_payment_by_method( message: types.Message, db_user: User, amount_kopeks: int, state: FSMContext, payment_method: str @@ -277,10 +286,11 @@ async def show_balance_history(callback: types.CallbackQuery, db_user: User, db: text = '📊 История операций\n\n' for transaction in unique_transactions: - emoji = '💰' if transaction.type == TransactionType.DEPOSIT.value else '💸' + is_credit = transaction.type in CREDIT_TRANSACTION_TYPES + emoji = '💰' if is_credit else '💸' amount_text = ( f'+{texts.format_price(transaction.amount_kopeks)}' - if transaction.type == TransactionType.DEPOSIT.value + if is_credit else f'-{texts.format_price(abs(transaction.amount_kopeks))}' ) diff --git a/app/services/referral_withdrawal_service.py b/app/services/referral_withdrawal_service.py index 21b846b2..a2344774 100644 --- a/app/services/referral_withdrawal_service.py +++ b/app/services/referral_withdrawal_service.py @@ -14,6 +14,7 @@ from app.config import settings from app.database.models import ( ReferralEarning, Transaction, + TransactionType, User, WithdrawalRequest, WithdrawalRequestStatus, @@ -41,10 +42,14 @@ class ReferralWithdrawalService: async def get_user_own_deposits(self, db: AsyncSession, user_id: int) -> int: """ Получает сумму собственных пополнений пользователя (НЕ реферальные). + Фильтрует по payment_method IS NOT NULL — реальные платежи всегда имеют payment_method. """ result = await db.execute( select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( - Transaction.user_id == user_id, Transaction.type == 'deposit', Transaction.is_completed == True + Transaction.user_id == user_id, + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed == True, + Transaction.payment_method.isnot(None), ) ) return result.scalar() or 0 @@ -65,7 +70,7 @@ class ReferralWithdrawalService: result = await db.execute( select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( Transaction.user_id == user_id, - Transaction.type.in_(['subscription_payment', 'withdrawal']), + Transaction.type.in_([TransactionType.SUBSCRIPTION_PAYMENT.value, TransactionType.WITHDRAWAL.value]), Transaction.is_completed == True, ) ) @@ -83,7 +88,7 @@ class ReferralWithdrawalService: result = await db.execute( select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( Transaction.user_id == user_id, - Transaction.type.in_(['subscription_payment', 'withdrawal']), + Transaction.type.in_([TransactionType.SUBSCRIPTION_PAYMENT.value, TransactionType.WITHDRAWAL.value]), Transaction.is_completed == True, Transaction.created_at >= first_earning_date, ) @@ -282,7 +287,7 @@ class ReferralWithdrawalService: ) .where( Transaction.user_id.in_(referral_ids), - Transaction.type == 'deposit', + Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, Transaction.created_at >= month_ago, ) @@ -331,7 +336,7 @@ class ReferralWithdrawalService: func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount'), ).where( Transaction.user_id.in_(referral_ids), - Transaction.type == 'deposit', + Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, ) ) @@ -499,7 +504,7 @@ class ReferralWithdrawalService: # Создаём транзакцию списания withdrawal_tx = Transaction( user_id=request.user_id, - type='withdrawal', + type=TransactionType.WITHDRAWAL.value, amount_kopeks=-request.amount_kopeks, description=f'Вывод реферального баланса (заявка #{request.id})', is_completed=True, diff --git a/app/utils/user_utils.py b/app/utils/user_utils.py index 3c294ef7..e0b90a1b 100644 --- a/app/utils/user_utils.py +++ b/app/utils/user_utils.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.config import settings -from app.database.models import ReferralEarning, Transaction, TransactionType, User +from app.database.models import ReferralEarning, Subscription, SubscriptionStatus, Transaction, TransactionType, User logger = structlog.get_logger(__name__) @@ -166,10 +166,16 @@ async def get_user_referral_summary(db: AsyncSession, user_id: int) -> dict: for row in earnings_by_type_result: earnings_by_type[row.reason] = {'count': row.count, 'total_amount_kopeks': row.total_amount} - active_referrals_count = 0 - for referral in referrals: - if referral.last_activity and referral.last_activity >= month_ago: - active_referrals_count += 1 + active_result = await db.execute( + select(func.count(func.distinct(User.id))) + .join(Subscription, User.id == Subscription.user_id) + .where( + User.referred_by_id == user_id, + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.end_date > func.now(), + ) + ) + active_referrals_count = active_result.scalar() or 0 return { 'invited_count': invited_count, diff --git a/migrations/alembic/versions/0014_fix_referral_transaction_types.py b/migrations/alembic/versions/0014_fix_referral_transaction_types.py new file mode 100644 index 00000000..f2c2bbc1 --- /dev/null +++ b/migrations/alembic/versions/0014_fix_referral_transaction_types.py @@ -0,0 +1,52 @@ +"""fix historical referral transactions recorded as deposit + +Revision ID: 0014 +Revises: 0013 +Create Date: 2026-03-02 + +Data-only migration: updates transactions.type from 'deposit' to 'referral_reward' +for historical referral commission records that were incorrectly saved as deposits. +Discriminator: payment_method IS NULL (real deposits always have payment_method) +plus description pattern matching as belt-and-suspenders safety. +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = '0014' +down_revision: Union[str, None] = '0013' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(""" + UPDATE transactions + SET type = 'referral_reward' + WHERE type = 'deposit' + AND payment_method IS NULL + AND ( + description ILIKE '%реферал%' + OR description ILIKE '%referral%' + OR description ILIKE '%комиссия%' + OR description ILIKE '%бонус за первое пополнение%' + OR description ILIKE '%бонус за реферала%' + ) + """) + + +def downgrade() -> None: + op.execute(""" + UPDATE transactions + SET type = 'deposit' + WHERE type = 'referral_reward' + AND payment_method IS NULL + AND ( + description ILIKE '%реферал%' + OR description ILIKE '%referral%' + OR description ILIKE '%комиссия%' + OR description ILIKE '%бонус за первое пополнение%' + OR description ILIKE '%бонус за реферала%' + ) + """)