fix: eliminate referral system inconsistencies

- Fix balance history display: referral_reward, refund, poll_reward now
  shown as credits (💰 +amount) instead of expenses
- Fix double-counting: remove all Transaction-based REFERRAL_REWARD sum
  queries from crud/referral.py, admin_stats.py, admin_users.py —
  ReferralEarning is now the single source of truth
- Unify "active referrals" definition across cabinet, bot, and admin:
  JOIN Subscription WHERE status=ACTIVE AND end_date > now()
- Add payment_method IS NOT NULL guard to get_user_own_deposits() to
  exclude referral rewards historically mistyped as deposits
- Replace hardcoded transaction type strings with TransactionType enum
  values in referral_withdrawal_service.py
- Add Alembic data migration (0014) to fix historical transactions:
  UPDATE deposit → referral_reward WHERE payment_method IS NULL and
  description matches referral patterns
This commit is contained in:
Fringg
2026-03-02 02:25:32 +03:00
parent 83c6db4834
commit 60c97f778b
8 changed files with 120 additions and 161 deletions
-47
View File
@@ -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:
+4 -5
View File
@@ -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
+17 -6
View File
@@ -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
+13 -90
View File
@@ -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,
+12 -2
View File
@@ -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 = '📊 <b>История операций</b>\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))}'
)
+11 -6
View File
@@ -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,
+11 -5
View File
@@ -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,
@@ -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 '%бонус за реферала%'
)
""")