diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index 0e040a82..cbe9a9e2 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -7,6 +7,7 @@ from .admin_ban_system import router as admin_ban_system_router from .admin_email_templates import router as admin_email_templates_router from .admin_broadcasts import router as admin_broadcasts_router from .admin_campaigns import router as admin_campaigns_router +from .admin_payment_methods import router as admin_payment_methods_router from .admin_payments import router as admin_payments_router from .admin_promo_offers import router as admin_promo_offers_router from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router @@ -77,6 +78,7 @@ router.include_router(admin_promocodes_router) router.include_router(admin_promo_groups_router) router.include_router(admin_campaigns_router) router.include_router(admin_users_router) +router.include_router(admin_payment_methods_router) router.include_router(admin_payments_router) router.include_router(admin_promo_offers_router) router.include_router(admin_remnawave_router) diff --git a/app/cabinet/routes/admin_payment_methods.py b/app/cabinet/routes/admin_payment_methods.py new file mode 100644 index 00000000..4e6de175 --- /dev/null +++ b/app/cabinet/routes/admin_payment_methods.py @@ -0,0 +1,228 @@ +"""Admin routes for payment method configuration in cabinet.""" + +import logging +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User +from app.services.payment_method_config_service import ( + _get_method_defaults, + get_all_configs, + get_all_promo_groups, + get_config_by_method_id, + update_config, + update_sort_order, +) + +from ..dependencies import get_cabinet_db, get_current_admin_user + + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix='/admin/payment-methods', tags=['Cabinet Admin Payment Methods']) + + +# ============ Schemas ============ + + +class SubOptionInfo(BaseModel): + id: str + name: str + + +class PaymentMethodConfigResponse(BaseModel): + method_id: str + sort_order: int + is_enabled: bool + display_name: str | None = None + default_display_name: str + sub_options: dict | None = None + available_sub_options: list[SubOptionInfo] | None = None + min_amount_kopeks: int | None = None + max_amount_kopeks: int | None = None + default_min_amount_kopeks: int + default_max_amount_kopeks: int + user_type_filter: str + first_topup_filter: str + promo_group_filter_mode: str + allowed_promo_group_ids: list[int] = Field(default_factory=list) + is_provider_configured: bool + created_at: datetime | None = None + updated_at: datetime | None = None + + class Config: + from_attributes = True + + +class PaymentMethodConfigUpdateRequest(BaseModel): + is_enabled: bool | None = None + display_name: str | None = Field(default=None, description='Null to reset to default') + sub_options: dict | None = None + min_amount_kopeks: int | None = Field(default=None, ge=0) + max_amount_kopeks: int | None = Field(default=None, ge=0) + user_type_filter: str | None = Field(default=None, pattern='^(all|telegram|email)$') + first_topup_filter: str | None = Field(default=None, pattern='^(any|yes|no)$') + promo_group_filter_mode: str | None = Field(default=None, pattern='^(all|selected)$') + allowed_promo_group_ids: list[int] | None = None + # Allow explicitly resetting display_name to null + reset_display_name: bool = False + reset_min_amount: bool = False + reset_max_amount: bool = False + + +class SortOrderRequest(BaseModel): + method_ids: list[str] + + +class PromoGroupSimple(BaseModel): + id: int + name: str + + class Config: + from_attributes = True + + +# ============ Helpers ============ + + +def _enrich_config(config, defaults: dict) -> PaymentMethodConfigResponse: + """Enrich a PaymentMethodConfig with env-var defaults.""" + method_def = defaults.get(config.method_id, {}) + + available_sub_options = None + raw_options = method_def.get('available_sub_options') + if raw_options: + available_sub_options = [SubOptionInfo(**opt) for opt in raw_options] + + return PaymentMethodConfigResponse( + method_id=config.method_id, + sort_order=config.sort_order, + is_enabled=config.is_enabled, + display_name=config.display_name, + default_display_name=method_def.get('default_display_name', config.method_id), + sub_options=config.sub_options, + available_sub_options=available_sub_options, + min_amount_kopeks=config.min_amount_kopeks, + max_amount_kopeks=config.max_amount_kopeks, + default_min_amount_kopeks=method_def.get('default_min', 1000), + default_max_amount_kopeks=method_def.get('default_max', 10000000), + user_type_filter=config.user_type_filter, + first_topup_filter=config.first_topup_filter, + promo_group_filter_mode=config.promo_group_filter_mode, + allowed_promo_group_ids=[pg.id for pg in config.allowed_promo_groups], + is_provider_configured=method_def.get('is_configured', False), + created_at=config.created_at, + updated_at=config.updated_at, + ) + + +# ============ Routes ============ + + +@router.get('', response_model=list[PaymentMethodConfigResponse]) +async def list_payment_methods( + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """List all payment method configurations.""" + configs = await get_all_configs(db) + defaults = _get_method_defaults() + return [_enrich_config(c, defaults) for c in configs] + + +@router.get('/promo-groups', response_model=list[PromoGroupSimple]) +async def list_promo_groups( + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """List all promo groups for filter selector.""" + groups = await get_all_promo_groups(db) + return [PromoGroupSimple(id=g.id, name=g.name) for g in groups] + + +@router.get('/{method_id}', response_model=PaymentMethodConfigResponse) +async def get_payment_method( + method_id: str, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get a single payment method configuration.""" + config = await get_config_by_method_id(db, method_id) + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f'Payment method not found: {method_id}', + ) + defaults = _get_method_defaults() + return _enrich_config(config, defaults) + + +@router.put('/order') +async def update_payment_methods_order( + request: SortOrderRequest, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Batch update sort order for payment methods.""" + await update_sort_order(db, request.method_ids) + logger.info(f'Admin {admin.id} updated payment methods order: {request.method_ids}') + return {'success': True} + + +@router.put('/{method_id}', response_model=PaymentMethodConfigResponse) +async def update_payment_method( + method_id: str, + request: PaymentMethodConfigUpdateRequest, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Update a payment method configuration.""" + # Build update data dict + data = {} + + if request.is_enabled is not None: + data['is_enabled'] = request.is_enabled + + if request.reset_display_name: + data['display_name'] = None + elif request.display_name is not None: + data['display_name'] = request.display_name.strip() or None + + if request.sub_options is not None: + data['sub_options'] = request.sub_options + + if request.reset_min_amount: + data['min_amount_kopeks'] = None + elif request.min_amount_kopeks is not None: + data['min_amount_kopeks'] = request.min_amount_kopeks + + if request.reset_max_amount: + data['max_amount_kopeks'] = None + elif request.max_amount_kopeks is not None: + data['max_amount_kopeks'] = request.max_amount_kopeks + + if request.user_type_filter is not None: + data['user_type_filter'] = request.user_type_filter + + if request.first_topup_filter is not None: + data['first_topup_filter'] = request.first_topup_filter + + if request.promo_group_filter_mode is not None: + data['promo_group_filter_mode'] = request.promo_group_filter_mode + + promo_group_ids = request.allowed_promo_group_ids + + config = await update_config(db, method_id, data, promo_group_ids) + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f'Payment method not found: {method_id}', + ) + + logger.info(f'Admin {admin.id} updated payment method config: {method_id}') + + defaults = _get_method_defaults() + return _enrich_config(config, defaults) diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index 34306627..261fec82 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -127,179 +127,149 @@ async def get_transactions( ) -@router.get('/payment-methods', response_model=list[PaymentMethodResponse]) -async def get_payment_methods(): - """Get available payment methods.""" - methods = [] +async def _get_available_payment_methods( + db: AsyncSession, + user: User, +) -> list[PaymentMethodResponse]: + """Get available payment methods filtered by DB config and user context. - # 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)'}, - ], - ) - ) + 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, + ) - # 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, - ) - ) + configs = await get_all_configs(db) + defaults = _get_method_defaults() - # 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, - ) - ) + # 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(), + } - # 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, - ) - ) - - # 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( - { + # 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) + + 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 + + # 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 + + # 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 + + # 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 + + # 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) methods.append( PaymentMethodResponse( - 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, - ) - ) - - # 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, + id=mid, + name=display_name, + description=None, + min_amount_kopeks=min_amount, + max_amount_kopeks=max_amount, 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, @@ -401,7 +371,7 @@ async def create_topup( ): """Create payment for balance top-up.""" # Validate payment method - methods = await get_payment_methods() + methods = await _get_available_payment_methods(db, user) method = next((m for m in methods if m.id == request.payment_method), None) if not method or not method.is_available: