diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index 2682bc78..1a048d82 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -127,152 +127,192 @@ async def get_transactions( ) -async def _get_available_payment_methods( - db: AsyncSession, - user: User, -) -> list[PaymentMethodResponse]: - """Get available payment methods filtered by DB config and user context. - - Combines env-var availability with DB-based admin config (ordering, display conditions). - """ - from app.services.payment_method_config_service import ( - _get_method_defaults, - get_all_configs, - ) - - configs = await get_all_configs(db) - defaults = _get_method_defaults() - - # Provider availability checks from env vars - provider_enabled = { - 'telegram_stars': settings.TELEGRAM_STARS_ENABLED, - 'tribute': settings.TRIBUTE_ENABLED and bool(getattr(settings, 'TRIBUTE_DONATE_LINK', '')), - 'cryptobot': settings.is_cryptobot_enabled(), - 'heleket': settings.is_heleket_enabled(), - 'yookassa': settings.is_yookassa_enabled(), - 'mulenpay': settings.is_mulenpay_enabled(), - 'pal24': settings.is_pal24_enabled(), - 'platega': settings.is_platega_enabled(), - 'wata': settings.is_wata_enabled(), - 'freekassa': settings.is_freekassa_enabled(), - 'cloudpayments': settings.is_cloudpayments_enabled(), - 'kassa_ai': settings.is_kassa_ai_enabled(), - } - - # Default options builder (for methods with sub-options) - def _build_options(method_id: str, config_sub_options: dict | None) -> list[dict] | None: - if method_id == 'yookassa': - all_opts = [ - {'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'}, - {'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей (QR)'}, - ] - elif method_id == 'pal24': - all_opts = [ - {'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'}, - {'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'}, - ] - elif method_id == 'platega': - platega_methods = settings.get_platega_active_methods() - definitions = settings.get_platega_method_definitions() - all_opts = [] - for method_code in platega_methods: - info = definitions.get(method_code, {}) - all_opts.append( - { - 'id': str(method_code), - 'name': info.get('title') or info.get('name') or f'Platega {method_code}', - 'description': info.get('description') or info.get('name') or '', - } - ) - elif method_id == 'freekassa': - all_opts = [ - {'id': 'sbp', 'name': '🏦 NSPK СБП', 'description': 'Система быстрых платежей'}, - {'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'}, - ] - elif method_id == 'cloudpayments': - all_opts = [ - {'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'}, - {'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'}, - ] - else: - return None - - if not all_opts: - return None - - # Filter by sub_options config from DB - if config_sub_options: - all_opts = [o for o in all_opts if config_sub_options.get(o['id'], True)] - - return all_opts if all_opts else None - - # User promo group IDs for filtering - user_promo_group_ids: set[int] = set() - if hasattr(user, 'user_promo_groups') and user.user_promo_groups: - for upg in user.user_promo_groups: - user_promo_group_ids.add(upg.promo_group_id) - if hasattr(user, 'promo_group_id') and user.promo_group_id: - user_promo_group_ids.add(user.promo_group_id) - +@router.get('/payment-methods', response_model=list[PaymentMethodResponse]) +async def get_payment_methods(): + """Get available payment methods.""" methods = [] - for config in configs: - mid = config.method_id - # 1. Check env-var provider availability AND DB admin toggle - if not provider_enabled.get(mid, False): - continue - if not config.is_enabled: - continue + # YooKassa - with card and SBP options + if settings.is_yookassa_enabled(): + methods.append( + PaymentMethodResponse( + id='yookassa', + name=settings.get_yookassa_display_name(), + description='Pay via YooKassa', + min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, + is_available=True, + options=[ + {'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'}, + {'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей (QR)'}, + ], + ) + ) - # 2. Check user type filter - if config.user_type_filter == 'telegram' and user.auth_type != 'telegram': - continue - if config.user_type_filter == 'email' and user.auth_type != 'email': - continue + # CryptoBot + if settings.is_cryptobot_enabled(): + methods.append( + PaymentMethodResponse( + id='cryptobot', + name=settings.get_cryptobot_display_name(), + description='Pay with cryptocurrency via CryptoBot', + min_amount_kopeks=1000, + max_amount_kopeks=10000000, + is_available=True, + ) + ) - # 3. Check first topup filter - if config.first_topup_filter == 'yes' and not user.has_made_first_topup: - continue - if config.first_topup_filter == 'no' and user.has_made_first_topup: - continue + # Telegram Stars + if settings.TELEGRAM_STARS_ENABLED: + methods.append( + PaymentMethodResponse( + id='telegram_stars', + name=settings.get_telegram_stars_display_name(), + description='Pay with Telegram Stars', + min_amount_kopeks=100, + max_amount_kopeks=1000000, + is_available=True, + ) + ) - # 4. Check promo group filter - if config.promo_group_filter_mode == 'selected' and config.allowed_promo_groups: - allowed_ids = {pg.id for pg in config.allowed_promo_groups} - if not user_promo_group_ids.intersection(allowed_ids): - continue + # Heleket + if settings.is_heleket_enabled(): + methods.append( + PaymentMethodResponse( + id='heleket', + name=settings.get_heleket_display_name(), + description='Pay with cryptocurrency via Heleket', + min_amount_kopeks=1000, + max_amount_kopeks=10000000, + is_available=True, + ) + ) - # Build the response - method_def = defaults.get(mid, {}) - display_name = config.display_name or method_def.get('default_display_name', mid) - min_amount = config.min_amount_kopeks or method_def.get('default_min', 1000) - max_amount = config.max_amount_kopeks or method_def.get('default_max', 10000000) - options = _build_options(mid, config.sub_options) + # MulenPay + if settings.is_mulenpay_enabled(): + methods.append( + PaymentMethodResponse( + id='mulenpay', + name=settings.get_mulenpay_display_name(), + description='MulenPay payment', + min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS, + is_available=True, + ) + ) + + # PAL24 - add options for card/sbp + if settings.is_pal24_enabled(): + methods.append( + PaymentMethodResponse( + id='pal24', + name=settings.get_pal24_display_name(), + description='Pay via PAL24', + min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS, + is_available=True, + options=[ + {'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'}, + {'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'}, + ], + ) + ) + + # Platega - add options for different payment methods + if settings.is_platega_enabled(): + platega_methods = settings.get_platega_active_methods() + definitions = settings.get_platega_method_definitions() + platega_options = [] + for method_code in platega_methods: + info = definitions.get(method_code, {}) + platega_options.append( + { + 'id': str(method_code), + 'name': info.get('title') or info.get('name') or f'Platega {method_code}', + 'description': info.get('description') or info.get('name') or '', + } + ) methods.append( PaymentMethodResponse( - id=mid, - name=display_name, - description=None, - min_amount_kopeks=min_amount, - max_amount_kopeks=max_amount, + id='platega', + name=settings.get_platega_display_name(), + description='Pay via Platega', + min_amount_kopeks=settings.PLATEGA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.PLATEGA_MAX_AMOUNT_KOPEKS, + is_available=True, + options=platega_options if platega_options else None, + ) + ) + + # Wata + if settings.is_wata_enabled(): + methods.append( + PaymentMethodResponse( + id='wata', + name=settings.get_wata_display_name(), + description='Pay via Wata', + min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS, + is_available=True, + ) + ) + + # CloudPayments + if settings.is_cloudpayments_enabled(): + methods.append( + PaymentMethodResponse( + id='cloudpayments', + name=settings.get_cloudpayments_display_name(), + description='Pay with bank card via CloudPayments', + min_amount_kopeks=settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS, + is_available=True, + ) + ) + + # FreeKassa + if settings.is_freekassa_enabled(): + methods.append( + PaymentMethodResponse( + id='freekassa', + name=settings.get_freekassa_display_name(), + description='Pay via FreeKassa', + min_amount_kopeks=settings.FREEKASSA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.FREEKASSA_MAX_AMOUNT_KOPEKS, + is_available=True, + ) + ) + + # KassaAI + if settings.is_kassa_ai_enabled(): + methods.append( + PaymentMethodResponse( + id='kassa_ai', + name=settings.get_kassa_ai_display_name(), + description='Pay via KassaAI', + min_amount_kopeks=settings.KASSA_AI_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.KASSA_AI_MAX_AMOUNT_KOPEKS, + is_available=True, + ) + ) + + # Tribute + if settings.TRIBUTE_ENABLED and settings.TRIBUTE_DONATE_LINK: + methods.append( + PaymentMethodResponse( + id='tribute', + name='Tribute', + description='Pay with bank card via Tribute', + min_amount_kopeks=10000, + max_amount_kopeks=10000000, is_available=True, - options=options, ) ) return methods -@router.get('/payment-methods', response_model=list[PaymentMethodResponse]) -async def get_payment_methods( - user: User = Depends(get_current_cabinet_user), - db: AsyncSession = Depends(get_cabinet_db), -): - """Get available payment methods.""" - return await _get_available_payment_methods(db, user) - - @router.post('/stars-invoice', response_model=StarsInvoiceResponse) async def create_stars_invoice( request: StarsInvoiceRequest, @@ -374,7 +414,7 @@ async def create_topup( ): """Create payment for balance top-up.""" # Validate payment method - methods = await _get_available_payment_methods(db, user) + methods = await get_payment_methods() method = next((m for m in methods if m.id == request.payment_method), None) if not method or not method.is_available: @@ -705,7 +745,7 @@ async def create_topup( if not settings.is_kassa_ai_enabled(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail='Kassa AI payment method is unavailable', + detail='KassaAI payment method is unavailable', ) payment_service = PaymentService() @@ -724,7 +764,7 @@ async def create_topup( else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail='Failed to create Kassa AI payment', + detail='Failed to create KassaAI payment', ) elif request.payment_method == 'tribute': @@ -867,6 +907,17 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]: } return mapping.get(status, ('❓', 'Неизвестно')) + if record.method == PaymentMethod.KASSA_AI: + mapping = { + 'pending': ('⏳', 'Ожидает оплаты'), + 'success': ('✅', 'Оплачено'), + 'paid': ('✅', 'Оплачено'), + 'canceled': ('❌', 'Отменено'), + 'failed': ('❌', 'Ошибка'), + 'expired': ('⌛', 'Истёк'), + } + return mapping.get(status, ('❓', 'Неизвестно')) + return '❓', 'Неизвестно' @@ -895,6 +946,8 @@ def _is_checkable(record: PendingPayment) -> bool: return status in {'pending', 'authorized'} if record.method == PaymentMethod.FREEKASSA: return status in {'pending', 'created', 'processing'} + if record.method == PaymentMethod.KASSA_AI: + return status in {'pending', 'created', 'processing'} return False @@ -918,7 +971,7 @@ def _get_payment_url(record: PendingPayment) -> str | None: ) elif record.method == PaymentMethod.PLATEGA: payment_url = getattr(payment, 'redirect_url', None) or payment_url - elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA: + elif record.method in (PaymentMethod.CLOUDPAYMENTS, PaymentMethod.FREEKASSA, PaymentMethod.KASSA_AI): payment_url = getattr(payment, 'payment_url', None) or payment_url return payment_url diff --git a/app/database/crud/referral_contest.py b/app/database/crud/referral_contest.py index 27ed54fe..df35d676 100644 --- a/app/database/crud/referral_contest.py +++ b/app/database/crud/referral_contest.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import selectinload from app.database.models import ( ReferralContest, ReferralContestEvent, + ReferralContestVirtualParticipant, Transaction, TransactionType, User, @@ -918,3 +919,96 @@ async def cleanup_invalid_contest_events( 'contest_start': contest_start.isoformat(), 'contest_end': contest_end.isoformat(), } + + +# ── Виртуальные участники ────────────────────────────────────────────── + + +async def add_virtual_participant( + db: AsyncSession, + contest_id: int, + display_name: str, + referral_count: int, + total_amount_kopeks: int = 0, +) -> ReferralContestVirtualParticipant: + vp = ReferralContestVirtualParticipant( + contest_id=contest_id, + display_name=display_name, + referral_count=referral_count, + total_amount_kopeks=total_amount_kopeks, + ) + db.add(vp) + await db.commit() + await db.refresh(vp) + return vp + + +async def list_virtual_participants( + db: AsyncSession, + contest_id: int, +) -> Sequence[ReferralContestVirtualParticipant]: + result = await db.execute( + select(ReferralContestVirtualParticipant) + .where(ReferralContestVirtualParticipant.contest_id == contest_id) + .order_by(ReferralContestVirtualParticipant.referral_count.desc()) + ) + return result.scalars().all() + + +async def delete_virtual_participant( + db: AsyncSession, + participant_id: int, +) -> bool: + result = await db.execute( + select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == participant_id) + ) + vp = result.scalar_one_or_none() + if not vp: + return False + await db.delete(vp) + await db.commit() + return True + + +async def update_virtual_participant_count( + db: AsyncSession, + participant_id: int, + referral_count: int, +) -> ReferralContestVirtualParticipant | None: + result = await db.execute( + select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == participant_id) + ) + vp = result.scalar_one_or_none() + if not vp: + return None + vp.referral_count = referral_count + await db.commit() + await db.refresh(vp) + return vp + + +async def get_contest_leaderboard_with_virtual( + db: AsyncSession, + contest_id: int, + *, + limit: int | None = None, +) -> list[tuple[str, int, int, bool]]: + """Лидерборд с виртуальными участниками. + + Возвращает список кортежей (display_name, referral_count, total_amount, is_virtual). + """ + real = await get_contest_leaderboard(db, contest_id) + virtual = await list_virtual_participants(db, contest_id) + + merged: list[tuple[str, int, int, bool]] = [] + for user, score, amount in real: + merged.append((user.full_name, score, amount, False)) + for vp in virtual: + merged.append((vp.display_name, vp.referral_count, vp.total_amount_kopeks, True)) + + merged.sort(key=lambda x: (-x[1], -x[2])) + + if limit: + merged = merged[:limit] + + return merged diff --git a/app/database/models.py b/app/database/models.py index e3ad8598..3e15ab3f 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1548,6 +1548,24 @@ class ReferralContestEvent(Base): ) +class ReferralContestVirtualParticipant(Base): + __tablename__ = 'referral_contest_virtual_participants' + + id = Column(Integer, primary_key=True, index=True) + contest_id = Column(Integer, ForeignKey('referral_contests.id', ondelete='CASCADE'), nullable=False) + display_name = Column(String(255), nullable=False) + referral_count = Column(Integer, nullable=False, default=0) + total_amount_kopeks = Column(Integer, nullable=False, default=0) + created_at = Column(DateTime, default=func.now()) + + contest = relationship('ReferralContest') + + def __repr__(self): + return ( + f"" + ) + + class ContestTemplate(Base): __tablename__ = 'contest_templates' diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 1909b1ad..f31c445b 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1731,6 +1731,65 @@ async def create_referral_contest_events_table() -> bool: return False +async def create_referral_contest_virtual_participants_table() -> bool: + table_exists = await check_table_exists('referral_contest_virtual_participants') + if table_exists: + logger.info('Таблица referral_contest_virtual_participants уже существует') + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute( + text(""" + CREATE TABLE referral_contest_virtual_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contest_id INTEGER NOT NULL, + display_name VARCHAR(255) NOT NULL, + referral_count INTEGER NOT NULL DEFAULT 0, + total_amount_kopeks INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE + ) + """) + ) + elif db_type == 'postgresql': + await conn.execute( + text(""" + CREATE TABLE referral_contest_virtual_participants ( + id SERIAL PRIMARY KEY, + contest_id INTEGER NOT NULL REFERENCES referral_contests(id) ON DELETE CASCADE, + display_name VARCHAR(255) NOT NULL, + referral_count INTEGER NOT NULL DEFAULT 0, + total_amount_kopeks INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + ) + else: + await conn.execute( + text(""" + CREATE TABLE referral_contest_virtual_participants ( + id INT AUTO_INCREMENT PRIMARY KEY, + contest_id INT NOT NULL, + display_name VARCHAR(255) NOT NULL, + referral_count INT NOT NULL DEFAULT 0, + total_amount_kopeks INT NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE + ) + """) + ) + + logger.info('✅ Таблица referral_contest_virtual_participants создана') + return True + except Exception as error: + logger.error(f'Ошибка создания таблицы referral_contest_virtual_participants: {error}') + return False + + async def ensure_referral_contest_summary_columns() -> bool: ok = True for column in ['daily_summary_times', 'last_daily_summary_at']: @@ -6459,6 +6518,12 @@ async def run_universal_migration(): else: logger.warning('⚠️ Проблемы с таблицей referral_contest_events') + virtual_participants_ready = await create_referral_contest_virtual_participants_table() + if virtual_participants_ready: + logger.info('✅ Таблица referral_contest_virtual_participants готова') + else: + logger.warning('⚠️ Проблемы с таблицей referral_contest_virtual_participants') + contest_type_ready = await ensure_referral_contest_type_column() if contest_type_ready: logger.info('✅ Колонка contest_type для referral_contests готова') diff --git a/app/handlers/admin/contests.py b/app/handlers/admin/contests.py index 4d8b326b..997cf902 100644 --- a/app/handlers/admin/contests.py +++ b/app/handlers/admin/contests.py @@ -9,15 +9,19 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.referral_contest import ( + add_virtual_participant, create_referral_contest, delete_referral_contest, + delete_virtual_participant, get_contest_events_count, - get_contest_leaderboard, + get_contest_leaderboard_with_virtual, get_referral_contest, get_referral_contests_count, list_referral_contests, + list_virtual_participants, toggle_referral_contest, update_referral_contest, + update_virtual_participant_count, ) from app.keyboards.admin import ( get_admin_contests_keyboard, @@ -240,8 +244,10 @@ async def show_contest_details( return tz = _ensure_timezone(contest.timezone or settings.TIMEZONE) - leaderboard = await get_contest_leaderboard(db, contest.id, limit=5) - total_events = await get_contest_events_count(db, contest.id) + leaderboard = await get_contest_leaderboard_with_virtual(db, contest.id, limit=5) + virtual_list = await list_virtual_participants(db, contest.id) + virtual_count = sum(vp.referral_count for vp in virtual_list) + total_events = await get_contest_events_count(db, contest.id) + virtual_count lines = [ f'🏆 {contest.title}', @@ -256,8 +262,9 @@ async def show_contest_details( if leaderboard: lines.append('') lines.append(texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:')) - for idx, (user, score, _) in enumerate(leaderboard, start=1): - lines.append(f'{idx}. {user.full_name} — {score}') + for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1): + virt_mark = ' 👻' if is_virtual else '' + lines.append(f'{idx}. {name}{virt_mark} — {score}') await callback.message.edit_text( '\n'.join(lines), @@ -427,7 +434,7 @@ async def show_leaderboard( await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True) return - leaderboard = await get_contest_leaderboard(db, contest_id, limit=10) + leaderboard = await get_contest_leaderboard_with_virtual(db, contest_id, limit=10) if not leaderboard: await callback.answer(texts.t('ADMIN_CONTEST_EMPTY_LEADERBOARD', 'Пока нет участников.'), show_alert=True) return @@ -435,9 +442,9 @@ async def show_leaderboard( lines = [ texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'), ] - for idx, (user, score, _) in enumerate(leaderboard, start=1): - user_id_display = user.telegram_id or user.email or f'#{user.id}' - lines.append(f'{idx}. {user.full_name} ({user_id_display}) — {score}') + for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1): + virt_mark = ' 👻' if is_virtual else '' + lines.append(f'{idx}. {name}{virt_mark} — {score}') await callback.message.edit_text( '\n'.join(lines), @@ -676,6 +683,9 @@ async def show_detailed_stats( from app.services.referral_contest_service import referral_contest_service stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id) + virtual = await list_virtual_participants(db, contest_id) + virtual_count = len(virtual) + virtual_referrals = sum(vp.referral_count for vp in virtual) # Общее сообщение с основной статистикой general_lines = [ @@ -693,6 +703,10 @@ async def show_detailed_stats( f' 📥 Пополнения баланса: {stats.get("deposit_total", 0) // 100} руб.', ] + if virtual_count > 0: + general_lines.append('') + general_lines.append(f'👻 Виртуальных: {virtual_count} (рефералов: {virtual_referrals})') + await callback.message.edit_text( '\n'.join(general_lines), reply_markup=get_referral_contest_manage_keyboard( @@ -970,6 +984,274 @@ async def debug_contest_transactions( ) +# ── Виртуальные участники ────────────────────────────────────────────── + + +@admin_required +@error_handler +async def show_virtual_participants( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + contest_id = int(callback.data.split('_')[-1]) + contest = await get_referral_contest(db, contest_id) + if not contest: + await callback.answer('Конкурс не найден.', show_alert=True) + return + + vps = await list_virtual_participants(db, contest_id) + + lines = [f'👻 Виртуальные участники — {contest.title}', ''] + if vps: + for vp in vps: + lines.append(f'• {vp.display_name} — {vp.referral_count} реф.') + else: + lines.append('Пока нет виртуальных участников.') + + rows = [ + [ + types.InlineKeyboardButton( + text='➕ Добавить', + callback_data=f'admin_contest_vp_add_{contest_id}', + ), + ], + ] + if vps: + for vp in vps: + rows.append( + [ + types.InlineKeyboardButton( + text=f'✏️ {vp.display_name}', + callback_data=f'admin_contest_vp_edit_{vp.id}', + ), + types.InlineKeyboardButton( + text='🗑', + callback_data=f'admin_contest_vp_del_{vp.id}', + ), + ] + ) + rows.append( + [ + types.InlineKeyboardButton( + text='⬅️ Назад', + callback_data=f'admin_contest_view_{contest_id}', + ), + ] + ) + + await callback.message.edit_text( + '\n'.join(lines), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows), + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_add_virtual_participant( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, + state: FSMContext, +): + contest_id = int(callback.data.split('_')[-1]) + await state.set_state(AdminStates.adding_virtual_participant_name) + await state.update_data(vp_contest_id=contest_id) + await callback.message.edit_text( + '👻 Введите отображаемое имя виртуального участника:', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')], + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_virtual_participant_name( + message: types.Message, + db_user, + db: AsyncSession, + state: FSMContext, +): + name = message.text.strip() + if not name or len(name) > 200: + await message.answer('Имя должно быть от 1 до 200 символов. Попробуйте ещё раз:') + return + await state.update_data(vp_name=name) + await state.set_state(AdminStates.adding_virtual_participant_count) + await message.answer(f'Имя: {name}\n\nВведите количество рефералов (число):') + + +@admin_required +@error_handler +async def process_virtual_participant_count( + message: types.Message, + db_user, + db: AsyncSession, + state: FSMContext, +): + try: + count = int(message.text.strip()) + if count < 1: + raise ValueError + except (ValueError, TypeError): + await message.answer('Введите положительное целое число:') + return + + data = await state.get_data() + contest_id = data['vp_contest_id'] + display_name = data['vp_name'] + await state.clear() + + vp = await add_virtual_participant(db, contest_id, display_name, count) + await message.answer( + f'✅ Виртуальный участник добавлен:\nИмя: {vp.display_name}\nРефералов: {vp.referral_count}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='👻 К списку', callback_data=f'admin_contest_vp_{contest_id}')], + [types.InlineKeyboardButton(text='⬅️ К конкурсу', callback_data=f'admin_contest_view_{contest_id}')], + ] + ), + ) + + +@admin_required +@error_handler +async def delete_virtual_participant_handler( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + vp_id = int(callback.data.split('_')[-1]) + + # Получим contest_id до удаления + from sqlalchemy import select as sa_select + + from app.database.models import ReferralContestVirtualParticipant + + result = await db.execute( + sa_select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == vp_id) + ) + vp = result.scalar_one_or_none() + if not vp: + await callback.answer('Участник не найден.', show_alert=True) + return + + contest_id = vp.contest_id + deleted = await delete_virtual_participant(db, vp_id) + if deleted: + await callback.answer('✅ Удалён', show_alert=False) + else: + await callback.answer('Не удалось удалить.', show_alert=True) + + # Вернуться к списку + vps = await list_virtual_participants(db, contest_id) + contest = await get_referral_contest(db, contest_id) + + lines = [f'👻 Виртуальные участники — {contest.title}', ''] + if vps: + for v in vps: + lines.append(f'• {v.display_name} — {v.referral_count} реф.') + else: + lines.append('Пока нет виртуальных участников.') + + rows = [ + [types.InlineKeyboardButton(text='➕ Добавить', callback_data=f'admin_contest_vp_add_{contest_id}')], + ] + if vps: + for v in vps: + rows.append( + [ + types.InlineKeyboardButton( + text=f'✏️ {v.display_name}', callback_data=f'admin_contest_vp_edit_{v.id}' + ), + types.InlineKeyboardButton(text='🗑', callback_data=f'admin_contest_vp_del_{v.id}'), + ] + ) + rows.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_contest_view_{contest_id}')]) + + await callback.message.edit_text( + '\n'.join(lines), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows), + ) + + +@admin_required +@error_handler +async def start_edit_virtual_participant( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, + state: FSMContext, +): + vp_id = int(callback.data.split('_')[-1]) + + from sqlalchemy import select as sa_select + + from app.database.models import ReferralContestVirtualParticipant + + result = await db.execute( + sa_select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == vp_id) + ) + vp = result.scalar_one_or_none() + if not vp: + await callback.answer('Участник не найден.', show_alert=True) + return + + await state.set_state(AdminStates.editing_virtual_participant_count) + await state.update_data(vp_edit_id=vp_id, vp_edit_contest_id=vp.contest_id) + await callback.message.edit_text( + f'✏️ {vp.display_name}\n' + f'Текущее кол-во рефералов: {vp.referral_count}\n\n' + f'Введите новое количество:', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{vp.contest_id}')], + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_virtual_participant_count( + message: types.Message, + db_user, + db: AsyncSession, + state: FSMContext, +): + try: + count = int(message.text.strip()) + if count < 1: + raise ValueError + except (ValueError, TypeError): + await message.answer('Введите положительное целое число:') + return + + data = await state.get_data() + vp_id = data['vp_edit_id'] + contest_id = data['vp_edit_contest_id'] + await state.clear() + + vp = await update_virtual_participant_count(db, vp_id, count) + if vp: + await message.answer( + f'✅ Обновлено: {vp.display_name} — {vp.referral_count} реф.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='👻 К списку', callback_data=f'admin_contest_vp_{contest_id}')], + ] + ), + ) + else: + await message.answer('Участник не найден.') + + def register_handlers(dp: Dispatcher): dp.callback_query.register(show_contests_menu, F.data == 'admin_contests') dp.callback_query.register(show_referral_contests_menu, F.data == 'admin_contests_referral') @@ -996,3 +1278,11 @@ def register_handlers(dp: Dispatcher): dp.message.register(process_end_date, AdminStates.creating_referral_contest_end) dp.message.register(finalize_contest_creation, AdminStates.creating_referral_contest_time) dp.message.register(process_edit_summary_times, AdminStates.editing_referral_contest_summary_times) + + dp.callback_query.register(start_add_virtual_participant, F.data.startswith('admin_contest_vp_add_')) + dp.callback_query.register(delete_virtual_participant_handler, F.data.startswith('admin_contest_vp_del_')) + dp.callback_query.register(start_edit_virtual_participant, F.data.startswith('admin_contest_vp_edit_')) + dp.callback_query.register(show_virtual_participants, F.data.regexp(r'^admin_contest_vp_\d+$')) + dp.message.register(process_virtual_participant_name, AdminStates.adding_virtual_participant_name) + dp.message.register(process_virtual_participant_count, AdminStates.adding_virtual_participant_count) + dp.message.register(process_edit_virtual_participant_count, AdminStates.editing_virtual_participant_count) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 07fa68d2..4a1a17d9 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -649,6 +649,12 @@ def get_referral_contest_manage_keyboard( callback_data=f'admin_contest_edit_times_{contest_id}', ), ], + [ + InlineKeyboardButton( + text='👻 Виртуальные', + callback_data=f'admin_contest_vp_{contest_id}', + ), + ], [ InlineKeyboardButton( text='🔄 Синхронизация', diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py index b20f0a3f..0b432306 100644 --- a/app/services/payment_verification_service.py +++ b/app/services/payment_verification_service.py @@ -22,6 +22,7 @@ from app.database.models import ( CryptoBotPayment, FreekassaPayment, HeleketPayment, + KassaAiPayment, MulenPayPayment, Pal24Payment, PaymentMethod, @@ -109,6 +110,8 @@ def method_display_name(method: PaymentMethod) -> str: return 'CloudPayments' if method == PaymentMethod.FREEKASSA: return 'Freekassa' + if method == PaymentMethod.KASSA_AI: + return settings.get_kassa_ai_display_name() if method == PaymentMethod.TELEGRAM_STARS: return 'Telegram Stars' return method.value @@ -133,6 +136,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool: return settings.is_cloudpayments_enabled() if method == PaymentMethod.FREEKASSA: return settings.is_freekassa_enabled() + if method == PaymentMethod.KASSA_AI: + return settings.is_kassa_ai_enabled() return False @@ -356,6 +361,13 @@ def _is_freekassa_pending(payment: FreekassaPayment) -> bool: return status in {'pending', 'created', 'processing'} +def _is_kassa_ai_pending(payment: KassaAiPayment) -> bool: + if payment.is_paid: + return False + status = (payment.status or '').lower() + return status in {'pending', 'created', 'processing'} + + def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int: payload = payment.payload or '' match = re.search(r'_(\d+)$', payload) @@ -648,6 +660,31 @@ async def _fetch_freekassa_payments(db: AsyncSession, cutoff: datetime) -> list[ return records +async def _fetch_kassa_ai_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: + stmt = ( + select(KassaAiPayment) + .options(selectinload(KassaAiPayment.user)) + .where(KassaAiPayment.created_at >= cutoff) + .order_by(desc(KassaAiPayment.created_at)) + ) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_kassa_ai_pending(payment): + continue + record = _build_record( + PaymentMethod.KASSA_AI, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + ) + if record: + records.append(record) + return records + + async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: stmt = ( select(Transaction) @@ -694,6 +731,7 @@ async def list_recent_pending_payments( await _fetch_cryptobot_payments(db, cutoff), await _fetch_cloudpayments_payments(db, cutoff), await _fetch_freekassa_payments(db, cutoff), + await _fetch_kassa_ai_payments(db, cutoff), await _fetch_stars_transactions(db, cutoff), ) @@ -848,6 +886,20 @@ async def get_payment_record( is_paid=bool(payment.is_paid), ) + if method == PaymentMethod.KASSA_AI: + payment = await db.get(KassaAiPayment, local_payment_id) + if not payment: + return None + await db.refresh(payment, attribute_names=['user']) + return _build_record( + method, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + ) + if method == PaymentMethod.TELEGRAM_STARS: transaction = await db.get(Transaction, local_payment_id) if not transaction: diff --git a/app/services/referral_contest_service.py b/app/services/referral_contest_service.py index 734fb8c6..c39d2354 100644 --- a/app/services/referral_contest_service.py +++ b/app/services/referral_contest_service.py @@ -12,10 +12,11 @@ from app.config import settings from app.database.crud.referral_contest import ( add_contest_event, get_contest_events_count, - get_contest_leaderboard, + get_contest_leaderboard_with_virtual, get_contests_for_events, get_contests_for_summaries, get_referrer_score, + list_virtual_participants, mark_daily_summary_sent, mark_final_summary_sent, ) @@ -173,8 +174,10 @@ class ReferralContestService: day_start_utc = day_start_local.astimezone(UTC).replace(tzinfo=None) day_end_utc = day_end_local.astimezone(UTC).replace(tzinfo=None) - leaderboard = list(await get_contest_leaderboard(db, contest.id)) - total_events = await get_contest_events_count(db, contest.id) + leaderboard = await get_contest_leaderboard_with_virtual(db, contest.id) + virtual_participants = await list_virtual_participants(db, contest.id) + virtual_count = sum(vp.referral_count for vp in virtual_participants) + total_events = await get_contest_events_count(db, contest.id) + virtual_count today_events = await get_contest_events_count( db, contest.id, @@ -269,7 +272,7 @@ class ReferralContestService: self, *, contest: ReferralContest, - leaderboard: Sequence[tuple[User, int, int]], + leaderboard: Sequence[tuple[str, int, int, bool]], total_events: int, today_events: int, is_final: bool, @@ -293,10 +296,9 @@ class ReferralContestService: ] if leaderboard: - for idx, (user, score, _) in enumerate(leaderboard[:5], start=1): - name = user.full_name - user_id_display = user.telegram_id or user.email or f'#{user.id}' - lines.append(f'{idx}. {name} ({user_id_display}) — {score}') + for idx, (name, score, _, is_virtual) in enumerate(leaderboard[:5], start=1): + virt_mark = ' 👻' if is_virtual else '' + lines.append(f'{idx}. {name}{virt_mark} — {score}') else: lines.append('Пока нет участников.') @@ -318,7 +320,7 @@ class ReferralContestService: self, *, contest: ReferralContest, - leaderboard: Sequence[tuple[User, int, int]], + leaderboard: Sequence[tuple[str, int, int, bool]], total_events: int, today_events: int, is_final: bool, @@ -346,8 +348,8 @@ class ReferralContestService: ] if leaderboard: - for idx, (user, score, _) in enumerate(leaderboard[:5], start=1): - lines.append(f'{idx}. {user.full_name} — {score}') + for idx, (name, score, _, _is_virtual) in enumerate(leaderboard[:5], start=1): + lines.append(f'{idx}. {name} — {score}') else: lines.append('Пока нет участников.') diff --git a/app/states.py b/app/states.py index c567f40e..351880d0 100644 --- a/app/states.py +++ b/app/states.py @@ -115,6 +115,9 @@ class AdminStates(StatesGroup): creating_referral_contest_end = State() creating_referral_contest_time = State() editing_referral_contest_summary_times = State() + adding_virtual_participant_name = State() + adding_virtual_participant_count = State() + editing_virtual_participant_count = State() editing_daily_contest_field = State() editing_daily_contest_value = State()