diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index a6d3515d..ee60a303 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -69,6 +69,13 @@ from .wheel import router as wheel_router from .withdrawal import router as withdrawal_router +# Conditional imports +try: + from .apple_iap import router as apple_iap_router +except ImportError: + apple_iap_router = None + + # Main cabinet router router = APIRouter(prefix='/cabinet', tags=['Cabinet'], redirect_slashes=False) @@ -81,6 +88,11 @@ router.include_router(subscription_router) router.include_router(multi_tariff_subscription_router) router.include_router(balance_router) router.include_router(referral_router) + +# Apple IAP routes +if apple_iap_router is not None: + router.include_router(apple_iap_router) + router.include_router(partner_application_router) router.include_router(withdrawal_router) # Notifications router MUST be before tickets router to avoid route conflict diff --git a/app/cabinet/routes/apple_iap.py b/app/cabinet/routes/apple_iap.py new file mode 100644 index 00000000..ac317592 --- /dev/null +++ b/app/cabinet/routes/apple_iap.py @@ -0,0 +1,267 @@ +"""Apple In-App Purchase cabinet route.""" + +from datetime import UTC, datetime + +import structlog +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.apple_iap import ( + create_apple_transaction, +) +from app.database.crud.transaction import create_transaction as create_trans +from app.database.crud.user import lock_user_for_update +from app.database.models import PaymentMethod, TransactionType, User +from app.external.apple_iap import AppleIAPService +from app.utils.user_utils import format_referrer_info + +from ..dependencies import get_cabinet_db, get_current_cabinet_user +from ..schemas.apple_iap import ApplePurchaseRequest, ApplePurchaseResponse + + +logger = structlog.get_logger(__name__) + +router = APIRouter(tags=['Cabinet Apple IAP']) + + +def get_apple_iap_service() -> AppleIAPService: + return AppleIAPService() + + +@router.post('/apple-purchase', response_model=ApplePurchaseResponse) +async def apple_purchase( + request: ApplePurchaseRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), + apple_iap_service: AppleIAPService = Depends(get_apple_iap_service), +): + """Verify an Apple In-App Purchase and credit the user's balance. + + The iOS app calls this endpoint after a successful StoreKit transaction. + If the backend returns success=false, the iOS app will NOT finish the + transaction and will retry on next launch. + """ + if not settings.is_apple_iap_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Apple In-App Purchase is not enabled', + ) + + # Validate product ID + products = settings.get_apple_iap_products() + if request.product_id not in products: + logger.warning( + 'Unknown Apple product ID', + product_id=request.product_id, + user_id=user.id, + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Unknown product ID', + ) + + amount_kopeks = products[request.product_id] + + # Verify transaction with Apple Server API (no DB lock needed). + # verify_transaction automatically falls back Sandbox<->Production. + txn_info = await apple_iap_service.verify_transaction(request.transaction_id, settings.APPLE_IAP_ENVIRONMENT) + if not txn_info: + logger.warning( + 'Apple transaction verification failed', + transaction_id=request.transaction_id, + user_id=user.id, + ) + return ApplePurchaseResponse(success=False) + + # Validate transaction fields + validation_error = apple_iap_service.validate_transaction_info(txn_info, request.product_id) + if validation_error: + logger.warning( + 'Apple transaction validation failed', + error=validation_error, + transaction_id=request.transaction_id, + user_id=user.id, + ) + return ApplePurchaseResponse(success=False) + + # FIX 4: appAccountToken is mandatory -- reject if missing + app_account_token = txn_info.get('appAccountToken') + if not app_account_token: + logger.warning( + 'Apple appAccountToken missing -- rejecting transaction', + transaction_id=request.transaction_id, + user_id=user.id, + ) + return ApplePurchaseResponse(success=False) + + if app_account_token != str(user.id): + logger.warning( + 'Apple appAccountToken mismatch -- possible replay', + expected=str(user.id), + received=app_account_token, + transaction_id=request.transaction_id, + user_id=user.id, + ) + return ApplePurchaseResponse(success=False) + + # Detect sandbox transactions -- store actual environment from Apple's response + actual_environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT) + is_sandbox = actual_environment == 'Sandbox' + + if is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production': + # Sandbox transaction on a production server (e.g. App Review). + # Record it for audit but do NOT credit real balance. + logger.info( + 'Apple sandbox transaction on production -- storing without balance credit', + transaction_id=request.transaction_id, + product_id=request.product_id, + user_id=user.id, + ) + try: + async with db.begin_nested(): + await create_apple_transaction( + db=db, + user_id=user.id, + transaction_id=request.transaction_id, + original_transaction_id=txn_info.get('originalTransactionId'), + product_id=request.product_id, + bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID), + amount_kopeks=amount_kopeks, + environment='Sandbox', + ) + except IntegrityError: + pass # already stored + await db.commit() + return ApplePurchaseResponse(success=True) + + # Atomically insert transaction record -- unique constraint on transaction_id + # prevents double-spend even under concurrent requests. + apple_txn = None + try: + async with db.begin_nested(): + apple_txn = await create_apple_transaction( + db=db, + user_id=user.id, + transaction_id=request.transaction_id, + original_transaction_id=txn_info.get('originalTransactionId'), + product_id=request.product_id, + bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID), + amount_kopeks=amount_kopeks, + environment=actual_environment, + ) + except IntegrityError: + logger.info( + 'Apple transaction already processed (idempotent)', + transaction_id=request.transaction_id, + user_id=user.id, + ) + return ApplePurchaseResponse(success=True) + + # Create financial transaction record + transaction = await create_trans( + db=db, + user_id=user.id, + type=TransactionType.DEPOSIT, + amount_kopeks=amount_kopeks, + description=f'Пополнение через Apple IAP: {request.product_id}', + payment_method=PaymentMethod.APPLE_IAP, + external_id=request.transaction_id, + is_completed=True, + commit=False, + ) + + # FIX 9: Link AppleTransaction to financial Transaction via FK + if apple_txn and transaction: + apple_txn.transaction_id_fk = transaction.id + apple_txn.updated_at = datetime.now(UTC) + + # Lock user row and credit balance + user = await lock_user_for_update(db, user) + old_balance = user.balance_kopeks + was_first_topup = not user.has_made_first_topup + + user.balance_kopeks += amount_kopeks + # FIX 10: Update user.updated_at when modifying balance + user.updated_at = datetime.now(UTC) + + promo_group = user.get_primary_promo_group() + subscription = getattr(user, 'subscription', None) + referrer_info = format_referrer_info(user) + topup_status = 'Первое пополнение' if was_first_topup else 'Пополнение' + + await db.commit() + + # --- Post-payment side-effects (after atomic commit) --- + + from app.database.crud.transaction import emit_transaction_side_effects + + try: + await emit_transaction_side_effects( + db, + transaction, + amount_kopeks=amount_kopeks, + user_id=user.id, + type=TransactionType.DEPOSIT, + payment_method=PaymentMethod.APPLE_IAP, + external_id=request.transaction_id, + ) + except Exception as error: + logger.error('Ошибка emit_transaction_side_effects Apple IAP', error=error) + + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup(db, user.id, amount_kopeks, bot=None) + except Exception as error: + logger.error('Ошибка обработки реферального пополнения Apple IAP', error=error) + + if was_first_topup and not user.has_made_first_topup and not user.referred_by_id: + user.has_made_first_topup = True + await db.commit() + + await db.refresh(user) + + # Admin notification + cart auto-purchase + try: + from app.bot_factory import create_bot + + bot = create_bot() + try: + from app.services.admin_notification_service import AdminNotificationService + + notification_service = AdminNotificationService(bot) + await notification_service.send_balance_topup_notification( + user, + transaction, + old_balance, + topup_status=topup_status, + referrer_info=referrer_info, + subscription=subscription, + promo_group=promo_group, + db=db, + ) + except Exception as error: + logger.error('Ошибка отправки админ уведомления Apple IAP', error=error) + + try: + from app.services.payment.common import send_cart_notification_after_topup + + await send_cart_notification_after_topup(user, amount_kopeks, db, bot) + except Exception as error: + logger.error('Ошибка при работе с сохраненной корзиной Apple IAP', user_id=user.id, error=error) + finally: + await bot.session.close() + except Exception as error: + logger.error('Ошибка создания бота для уведомлений Apple IAP', error=error) + + logger.info( + 'Apple IAP purchase credited', + transaction_id=request.transaction_id, + product_id=request.product_id, + amount_kopeks=amount_kopeks, + user_id=user.id, + ) + + return ApplePurchaseResponse(success=True) diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py index a590b6da..7f30cba8 100644 --- a/app/cabinet/routes/auth.py +++ b/app/cabinet/routes/auth.py @@ -172,74 +172,112 @@ async def _process_campaign_bonus( db: AsyncSession, user: User, campaign_slug: str | None, + telegram_id: int | None = None, ) -> CampaignBonusInfo | None: - """Process campaign bonus for user during auth. Never raises.""" + """Process campaign bonus for user during auth. Never raises. + + If ``campaign_slug`` is not provided but ``telegram_id`` is given, the + function falls back to Redis ``pending_campaign:{telegram_id}`` -- populated + by the bot's /start handler when a user opens an advertising campaign link + but then completes registration via the cabinet WebApp (Telegram menu + button) instead of the bot dialog. The Redis entry is cleared after a + successful consumption attempt. + """ + pending_campaign_consumed = False + if not campaign_slug and telegram_id: + try: + from app.services.referral_service import get_pending_campaign + + pending = await get_pending_campaign(telegram_id) + if pending and pending.get('campaign_slug'): + campaign_slug = pending['campaign_slug'] + pending_campaign_consumed = True + logger.info( + 'Resolved campaign from Redis pending_campaign (cabinet)', + telegram_id=telegram_id, + campaign_slug=campaign_slug, + ) + except Exception as e: + logger.warning('Failed to check pending campaign', error=e) + if not campaign_slug: return None try: - campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True) - if not campaign: - return None + try: + campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True) + if not campaign: + return None - # Skip if user IS the campaign partner — prevent self-referral - if campaign.partner_user_id and campaign.partner_user_id == user.id: - logger.debug( - 'Skipping campaign attribution: user is the campaign partner', - user_id=user.id, - campaign_id=campaign.id, - ) - return None - - # Lock user row to prevent concurrent bonus application (race condition) - await db.execute(select(User).where(User.id == user.id).with_for_update()) - - existing = await get_campaign_registration_by_user(db, user.id) - if existing: - logger.debug('User already has campaign registration', user_id=user.id) - return None - - # Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан) - if campaign.partner_user_id and not user.referred_by_id: - user.referred_by_id = campaign.partner_user_id - await db.flush() - try: - from app.bot_factory import create_bot - - async with create_bot() as bot: - await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot) - logger.info( - 'Referral set from campaign partner', + # Skip if user IS the campaign partner — prevent self-referral + if campaign.partner_user_id and campaign.partner_user_id == user.id: + logger.debug( + 'Skipping campaign attribution: user is the campaign partner', user_id=user.id, - partner_user_id=campaign.partner_user_id, campaign_id=campaign.id, ) - except Exception as e: - logger.error('Failed to process referral from campaign partner', error=e) + return None - service = AdvertisingCampaignService() - result = await service.apply_campaign_bonus(db, user, campaign) - if not result.success: - return None + # Lock user row to prevent concurrent bonus application (race condition) + await db.execute(select(User).where(User.id == user.id).with_for_update()) - # Refresh user to get updated balance after bonus - await db.refresh(user) + existing = await get_campaign_registration_by_user(db, user.id) + if existing: + logger.debug('User already has campaign registration', user_id=user.id) + return None - return CampaignBonusInfo( - campaign_name=campaign.name, - bonus_type=result.bonus_type or campaign.bonus_type, - balance_kopeks=result.balance_kopeks, - subscription_days=result.subscription_days, - tariff_name=result.tariff_name, - ) - except Exception: - logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug) - try: - await db.rollback() - # Re-fetch user so session stays usable for the caller + # Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан) + if campaign.partner_user_id and not user.referred_by_id: + user.referred_by_id = campaign.partner_user_id + await db.flush() + try: + from app.bot_factory import create_bot + + async with create_bot() as bot: + await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot) + logger.info( + 'Referral set from campaign partner', + user_id=user.id, + partner_user_id=campaign.partner_user_id, + campaign_id=campaign.id, + ) + except Exception as e: + logger.error('Failed to process referral from campaign partner', error=e) + + service = AdvertisingCampaignService() + result = await service.apply_campaign_bonus(db, user, campaign) + if not result.success: + return None + + # Refresh user to get updated balance after bonus await db.refresh(user) + + return CampaignBonusInfo( + campaign_name=campaign.name, + bonus_type=result.bonus_type or campaign.bonus_type, + balance_kopeks=result.balance_kopeks, + subscription_days=result.subscription_days, + tariff_name=result.tariff_name, + ) except Exception: - logger.exception('Failed to rollback after campaign bonus error', user_id=user.id) - return None + logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug) + try: + await db.rollback() + # Re-fetch user so session stays usable for the caller + await db.refresh(user) + except Exception: + logger.exception('Failed to rollback after campaign bonus error', user_id=user.id) + return None + finally: + # Clear Redis pending_campaign whenever we consumed it. Done regardless + # of success — if processing failed (already applied, race, exception), + # we don't want to keep retrying on every subsequent login. + if pending_campaign_consumed and telegram_id: + try: + from app.services.referral_service import clear_pending_campaign + + await clear_pending_campaign(telegram_id) + except Exception: + pass async def _process_referral_code( @@ -584,8 +622,11 @@ async def auth_telegram( except Exception: pass - # Process campaign bonus - response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug) + # Process campaign bonus. + # Pass telegram_id so the function can fall back to Redis pending_campaign + # if the user came via /start in the bot but completed + # registration in the WebApp without an explicit campaign_slug. + response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=telegram_id) if response.campaign_bonus: response.user = _user_to_response(user) @@ -691,8 +732,8 @@ async def auth_telegram_widget( except Exception: pass - # Process campaign bonus - response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug) + # Process campaign bonus (pending_campaign Redis fallback for Telegram Login Widget) + response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=request.id) if response.campaign_bonus: response.user = _user_to_response(user) @@ -837,7 +878,8 @@ async def auth_telegram_oidc( except Exception: pass - response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug) + # Process campaign bonus (pending_campaign Redis fallback for Telegram OIDC) + response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=telegram_id) if response.campaign_bonus: response.user = _user_to_response(user) diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index c437ab7a..a77d8a8e 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -914,6 +914,99 @@ async def create_topup( detail='Failed to create AuraPay payment', ) + elif request.payment_method == 'jupiter': + if not settings.is_jupiter_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Jupiter payment method is unavailable', + ) + + payment_service = PaymentService() + payment_method_type = request.payment_option or None + result = await payment_service.create_jupiter_payment( + db=db, + user_id=user.id, + amount_kopeks=request.amount_kopeks, + description=settings.get_balance_payment_description( + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), + email=getattr(user, 'email', None), + language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, + payment_method_type=payment_method_type, + return_url=cabinet_success_url, + ) + + if result and result.get('payment_url'): + payment_url = result.get('payment_url') + payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending') + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to create Jupiter payment', + ) + + elif request.payment_method == 'donut': + if not settings.is_donut_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Donut payment method is unavailable', + ) + + payment_service = PaymentService() + payment_method_type = request.payment_option or None + result = await payment_service.create_donut_payment( + db=db, + user_id=user.id, + amount_kopeks=request.amount_kopeks, + description=settings.get_balance_payment_description( + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), + email=getattr(user, 'email', None), + language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, + payment_method_type=payment_method_type, + return_url=cabinet_success_url, + ) + + if result and result.get('payment_url'): + payment_url = result.get('payment_url') + payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending') + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to create Donut payment', + ) + + elif request.payment_method == 'lava': + if not settings.is_lava_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Lava payment method is unavailable', + ) + + payment_service = PaymentService() + payment_method_type = request.payment_option or None + result = await payment_service.create_lava_payment( + db=db, + user_id=user.id, + amount_kopeks=request.amount_kopeks, + description=settings.get_balance_payment_description( + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), + email=getattr(user, 'email', None), + language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, + payment_method_type=payment_method_type, + return_url=cabinet_success_url, + ) + + if result and result.get('payment_url'): + payment_url = result.get('payment_url') + payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending') + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to create Lava payment', + ) + else: # For other payment methods, redirect to bot raise HTTPException( @@ -1065,6 +1158,45 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]: } return mapping.get(status, ('❓', 'Неизвестно')) + if record.method == PaymentMethod.JUPITER: + mapping = { + 'pending': ('⏳', 'Ожидает оплаты'), + 'processing': ('⌛', 'Обрабатывается'), + 'success': ('✅', 'Оплачено'), + 'cancelled': ('❌', 'Отменено'), + 'declined': ('❌', 'Отклонено'), + 'error': ('❌', 'Ошибка'), + 'amount_mismatch': ('⚠️', 'Несовпадение суммы'), + } + return mapping.get(status, ('❓', 'Неизвестно')) + + if record.method == PaymentMethod.DONUT: + mapping = { + 'pending': ('⏳', 'Ожидает оплаты'), + 'created': ('⏳', 'Создано'), + 'processing': ('⌛', 'Обрабатывается'), + 'success': ('✅', 'Оплачено'), + 'cancelled': ('❌', 'Отменено'), + 'error': ('❌', 'Ошибка'), + 'amount_mismatch': ('⚠️', 'Несовпадение суммы'), + } + return mapping.get(status, ('❓', 'Неизвестно')) + + if record.method == PaymentMethod.LAVA: + mapping = { + 'pending': ('⏳', 'Ожидает оплаты'), + 'created': ('⏳', 'Создано'), + 'processing': ('⌛', 'Обрабатывается'), + 'success': ('✅', 'Оплачено'), + 'cancel': ('❌', 'Отменено'), + 'cancelled': ('❌', 'Отменено'), + 'expired': ('⌛', 'Истёк'), + 'failed': ('❌', 'Ошибка'), + 'error': ('❌', 'Ошибка'), + 'amount_mismatch': ('⚠️', 'Несовпадение суммы'), + } + return mapping.get(status, ('❓', 'Неизвестно')) + return '❓', 'Неизвестно' diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index c9fe20f2..3cc55da3 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -19,6 +19,7 @@ from .subscription_modules import ( devices_router, purchase_router, renewal_router, + revoke_router, servers_router, status_router, tariff_switch_router, @@ -50,3 +51,4 @@ router.include_router(servers_router) router.include_router(autopay_router) router.include_router(daily_router) router.include_router(tariff_switch_router) +router.include_router(revoke_router) diff --git a/app/cabinet/routes/subscription_modules/__init__.py b/app/cabinet/routes/subscription_modules/__init__.py index 0fa0b711..9e28e917 100644 --- a/app/cabinet/routes/subscription_modules/__init__.py +++ b/app/cabinet/routes/subscription_modules/__init__.py @@ -10,6 +10,7 @@ from .devices import router as devices_router from .multi_tariff import router as multi_tariff_router from .purchase import router as purchase_router from .renewal import router as renewal_router +from .revoke import router as revoke_router from .servers import router as servers_router from .status import router as status_router from .tariff_switch import router as tariff_switch_router @@ -23,6 +24,7 @@ __all__ = [ 'multi_tariff_router', 'purchase_router', 'renewal_router', + 'revoke_router', 'servers_router', 'status_router', 'tariff_switch_router', diff --git a/app/cabinet/routes/subscription_modules/revoke.py b/app/cabinet/routes/subscription_modules/revoke.py new file mode 100644 index 00000000..e3e812eb --- /dev/null +++ b/app/cabinet/routes/subscription_modules/revoke.py @@ -0,0 +1,92 @@ +"""Cabinet API endpoint for subscription reissue. + +POST /subscription/revoke +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import structlog +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.services.subscription_service import SubscriptionService + +from ...dependencies import get_cabinet_db, get_current_cabinet_user +from .helpers import resolve_subscription + + +logger = structlog.get_logger(__name__) + +router = APIRouter() + + +@router.post('/revoke') +async def revoke_subscription( + subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'), + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> dict: + """Revoke and reissue subscription (generate new connection link).""" + if not settings.is_subscription_revoke_enabled(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='Subscription reissue is not available', + ) + + # Reload user from current session + from app.database.crud.user import get_user_by_id + + fresh_user = await get_user_by_id(db, user.id) + if not fresh_user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found') + + subscription = await resolve_subscription(db, fresh_user, subscription_id) + if not subscription: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Subscription not found') + + if not subscription.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Only active subscriptions can be reissued', + ) + + # Check cooldown + if subscription.last_revoke_at: + elapsed = (datetime.now(UTC) - subscription.last_revoke_at).total_seconds() + cooldown = settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS + if elapsed < cooldown: + remaining = int(cooldown - elapsed) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f'Cooldown active. Try again in {remaining} seconds.', + headers={'Retry-After': str(remaining)}, + ) + + # Execute revoke + sub_service = SubscriptionService() + new_url = await sub_service.revoke_subscription(db, subscription) + + if not new_url: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to reissue subscription', + ) + + # Update cooldown timestamp + subscription.last_revoke_at = datetime.now(UTC) + await db.commit() + + logger.info( + 'Subscription revoked via cabinet API', + user_id=user.id, + subscription_id=subscription.id, + ) + + return { + 'success': True, + 'cooldown_seconds': settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS, + } diff --git a/app/cabinet/schemas/apple_iap.py b/app/cabinet/schemas/apple_iap.py new file mode 100644 index 00000000..ff65941a --- /dev/null +++ b/app/cabinet/schemas/apple_iap.py @@ -0,0 +1,23 @@ +"""Apple In-App Purchase schemas for cabinet.""" + +from pydantic import BaseModel, Field, field_validator + + +class ApplePurchaseRequest(BaseModel): + """Request to verify and credit an Apple IAP transaction.""" + + product_id: str = Field(..., description='Apple product ID (e.g. com.bitnet.vpnclient.topup.100)') + transaction_id: str = Field(..., min_length=1, max_length=64, description='Apple StoreKit transaction ID') + + @field_validator('transaction_id') + @classmethod + def transaction_id_must_be_numeric(cls, v: str) -> str: + if not v.isdigit(): + raise ValueError('transaction_id must contain only digits') + return v + + +class ApplePurchaseResponse(BaseModel): + """Response indicating whether the purchase was successfully credited.""" + + success: bool diff --git a/app/config.py b/app/config.py index 662c7bdd..8b6d28d7 100644 --- a/app/config.py +++ b/app/config.py @@ -286,6 +286,10 @@ class Settings(BaseSettings): DISPOSABLE_EMAIL_CHECK_ENABLED: bool = True + # Настройки перевыпуска подписки (revoke + regenerate link) + SUBSCRIPTION_REVOKE_ENABLED: bool = True + SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS: int = 900 # 15 minutes + # Настройки простой покупки SIMPLE_SUBSCRIPTION_ENABLED: bool = False SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30 @@ -616,6 +620,19 @@ class Settings(BaseSettings): SEVERPAY_RETURN_URL: str | None = None SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320 + # Apple In-App Purchase + APPLE_IAP_ENABLED: bool = False + APPLE_IAP_KEY_ID: str | None = None + APPLE_IAP_ISSUER_ID: str | None = None + APPLE_IAP_BUNDLE_ID: str = 'com.app.client' + APPLE_IAP_PRIVATE_KEY: str | None = None # .p8 key contents (PEM) + APPLE_IAP_PRIVATE_KEY_PATH: str | None = None # Alternative: path to .p8 file + APPLE_IAP_ENVIRONMENT: str = 'Production' # 'Sandbox' or 'Production' + APPLE_IAP_WEBHOOK_PATH: str = '/apple-iap-webhook' + APPLE_IAP_PRODUCTS: str = ( + '{"com.app.client.topup.100":10000,"com.app.client.topup.300":30000,"com.app.client.topup.500":50000}' + ) + # PayPear (paypear.ru) PAYPEAR_ENABLED: bool = False PAYPEAR_SHOP_ID: str | None = None @@ -668,6 +685,108 @@ class Settings(BaseSettings): AURAPAY_WEBHOOK_PATH: str = '/aurapay-webhook' AURAPAY_RETURN_URL: str | None = None AURAPAY_PAYMENT_LIFETIME_MINUTES: int = 60 + AURAPAY_SBP_ENABLED: bool = False + AURAPAY_SBP_DISPLAY_NAME: str = 'СБП (AuraPay)' + AURAPAY_CARD_ENABLED: bool = False + AURAPAY_CARD_DISPLAY_NAME: str = 'Карта (AuraPay)' + + # Antilopay (lk.antilopay.com) + ANTILOPAY_ENABLED: bool = False + ANTILOPAY_SECRET_ID: str | None = None + ANTILOPAY_PRIVATE_KEY: str | None = None + ANTILOPAY_PUBLIC_KEY: str | None = None + ANTILOPAY_PROJECT_ID: str | None = None + ANTILOPAY_DISPLAY_NAME: str = 'Antilopay' + ANTILOPAY_PRODUCT_NAME: str = 'VPN подписка' + ANTILOPAY_PRODUCT_TYPE: str = 'services' + ANTILOPAY_CURRENCY: str = 'RUB' + ANTILOPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽ + ANTILOPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽ + ANTILOPAY_WEBHOOK_PATH: str = '/antilopay-webhook' + ANTILOPAY_RETURN_URL: str | None = None + ANTILOPAY_PAYMENT_LIFETIME_MINUTES: int = 60 + ANTILOPAY_SBP_ENABLED: bool = False + ANTILOPAY_SBP_DISPLAY_NAME: str = 'СБП (Antilopay)' + ANTILOPAY_CARD_ENABLED: bool = False + ANTILOPAY_CARD_DISPLAY_NAME: str = 'Карта (Antilopay)' + ANTILOPAY_SBERPAY_ENABLED: bool = False + ANTILOPAY_SBERPAY_DISPLAY_NAME: str = 'SberPay (Antilopay)' + + # Jupiter (FPGate P2P v2.1, app.juppiter.tech) + JUPITER_ENABLED: bool = False + JUPITER_TOKEN: str | None = None + JUPITER_SECRET: str | None = None + JUPITER_BASE_URL: str = 'https://app.juppiter.tech' + JUPITER_METHOD_ID: str | None = None + JUPITER_METHOD_DESCRIPTION: str = 'SBP' + JUPITER_DISPLAY_NAME: str = 'Jupiter' + JUPITER_CURRENCY: str = 'RUB' + JUPITER_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽ + JUPITER_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽ + JUPITER_WEBHOOK_PATH: str = '/jupiter-webhook' + JUPITER_RETURN_URL: str | None = None + JUPITER_PAYMENT_LIFETIME_MINUTES: int = 60 + JUPITER_FALLBACK_EMAIL: str = 'user@vpn.bot' + JUPITER_FALLBACK_PHONE: str = '0000000000' + JUPITER_FALLBACK_NAME: str = 'User' + JUPITER_SBP_ENABLED: bool = False + JUPITER_SBP_DISPLAY_NAME: str = 'СБП (Jupiter)' + + # Donut (Donut P2P, gw.donut.business) + DONUT_ENABLED: bool = False + DONUT_TOKEN: str | None = None + DONUT_SECRET: str | None = None + DONUT_BASE_URL: str = 'https://gw.donut.business' + DONUT_METHOD_ID: str | None = None + DONUT_DISPLAY_NAME: str = 'Donut' + DONUT_CURRENCY: str = 'RUB' + DONUT_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽ + DONUT_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽ + DONUT_WEBHOOK_PATH: str = '/donut-webhook' + DONUT_RETURN_URL: str | None = None + DONUT_PAYMENT_LIFETIME_MINUTES: int = 60 + # Sub-методы Donut (description в PayIn запросе) + DONUT_CARD_ENABLED: bool = False + DONUT_CARD_DISPLAY_NAME: str = 'Карта (Donut)' + DONUT_SBP_ENABLED: bool = False + DONUT_SBP_DISPLAY_NAME: str = 'СБП (Donut)' + DONUT_SBP_QR_ENABLED: bool = False + DONUT_SBP_QR_DISPLAY_NAME: str = 'СБП QR (Donut)' + + # Lava (Lava Business API, gate.lava.ru) + LAVA_ENABLED: bool = False + LAVA_BASE_URL: str = 'https://gate.lava.ru' + LAVA_SHOP_ID: str | None = None # UUID проекта + LAVA_SECRET_KEY: str | None = None # secret_key — для подписи запросов + LAVA_WEBHOOK_SECRET: str | None = None # secret_key_2 — для проверки подписи webhook + LAVA_DISPLAY_NAME: str = 'Lava' + LAVA_CURRENCY: str = 'RUB' + LAVA_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽ + LAVA_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽ + LAVA_WEBHOOK_PATH: str = '/lava-webhook' + LAVA_RETURN_URL: str | None = None + LAVA_PAYMENT_LIFETIME_MINUTES: int = 60 # макс 7200 минут (5 дней) + # Sub-методы Lava (фильтр через includeService/excludeService на стороне Lava) + LAVA_CARD_ENABLED: bool = False + LAVA_CARD_DISPLAY_NAME: str = 'Карта (Lava)' + LAVA_SBP_ENABLED: bool = False + LAVA_SBP_DISPLAY_NAME: str = 'СБП (Lava)' + + # Etoplatezhi (paymentpage.etoplatezhi.ru) + ETOPLATEZHI_ENABLED: bool = False + ETOPLATEZHI_PROJECT_ID: int | None = None + ETOPLATEZHI_SECRET_KEY: str | None = None + ETOPLATEZHI_DISPLAY_NAME: str = 'Etoplatezhi' + ETOPLATEZHI_CURRENCY: str = 'RUB' + ETOPLATEZHI_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽ + ETOPLATEZHI_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽ + ETOPLATEZHI_WEBHOOK_PATH: str = '/etoplatezhi-webhook' + ETOPLATEZHI_RETURN_URL: str | None = None + ETOPLATEZHI_PAYMENT_LIFETIME_MINUTES: int = 60 + ETOPLATEZHI_SBP_ENABLED: bool = False + ETOPLATEZHI_SBP_DISPLAY_NAME: str = 'СБП (Etoplatezhi)' + ETOPLATEZHI_CARD_ENABLED: bool = False + ETOPLATEZHI_CARD_DISPLAY_NAME: str = 'Карта (Etoplatezhi)' MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet' # Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции) @@ -1748,6 +1867,10 @@ class Settings(BaseSettings): def get_disabled_mode_device_limit(self) -> int | None: return self.get_devices_selection_disabled_amount() + def is_subscription_revoke_enabled(self) -> bool: + """Проверяет, включен ли перевыпуск подписки.""" + return self.SUBSCRIPTION_REVOKE_ENABLED + def is_multi_tariff_enabled(self) -> bool: """Проверяет, включен ли мультитарифный режим.""" return self.MULTI_TARIFF_ENABLED and self.SALES_MODE == 'tariffs' @@ -2027,6 +2150,34 @@ class Settings(BaseSettings): def get_severpay_display_name_html(self) -> str: return html.escape(self.get_severpay_display_name()) + def is_apple_iap_enabled(self) -> bool: + return ( + self.APPLE_IAP_ENABLED + and self.APPLE_IAP_KEY_ID is not None + and self.APPLE_IAP_ISSUER_ID is not None + and (self.APPLE_IAP_PRIVATE_KEY is not None or self.APPLE_IAP_PRIVATE_KEY_PATH is not None) + ) + + def get_apple_iap_products(self) -> dict[str, int]: + """Return mapping of Apple product ID -> kopeks amount.""" + import json as _json + + try: + return _json.loads(self.APPLE_IAP_PRODUCTS) + except Exception: + return {} + + def get_apple_iap_private_key(self) -> str | None: + """Return the .p8 private key contents.""" + if self.APPLE_IAP_PRIVATE_KEY: + return self.APPLE_IAP_PRIVATE_KEY + if self.APPLE_IAP_PRIVATE_KEY_PATH: + try: + return Path(self.APPLE_IAP_PRIVATE_KEY_PATH).read_text().strip() + except Exception: + return None + return None + def is_paypear_enabled(self) -> bool: return self.PAYPEAR_ENABLED and self.PAYPEAR_SHOP_ID is not None and self.PAYPEAR_SECRET_KEY is not None @@ -2077,6 +2228,201 @@ class Settings(BaseSettings): def get_aurapay_display_name_html(self) -> str: return html.escape(self.get_aurapay_display_name()) + def is_aurapay_sbp_enabled(self) -> bool: + return self.AURAPAY_SBP_ENABLED and self.is_aurapay_enabled() + + def get_aurapay_sbp_display_name(self) -> str: + name = (self.AURAPAY_SBP_DISPLAY_NAME or '').strip() + return name or 'СБП (AuraPay)' + + def get_aurapay_sbp_display_name_html(self) -> str: + return html.escape(self.get_aurapay_sbp_display_name()) + + def is_aurapay_card_enabled(self) -> bool: + return self.AURAPAY_CARD_ENABLED and self.is_aurapay_enabled() + + def get_aurapay_card_display_name(self) -> str: + name = (self.AURAPAY_CARD_DISPLAY_NAME or '').strip() + return name or 'Карта (AuraPay)' + + def get_aurapay_card_display_name_html(self) -> str: + return html.escape(self.get_aurapay_card_display_name()) + + def is_antilopay_enabled(self) -> bool: + return ( + self.ANTILOPAY_ENABLED + and self.ANTILOPAY_SECRET_ID is not None + and self.ANTILOPAY_PRIVATE_KEY is not None + and self.ANTILOPAY_PUBLIC_KEY is not None + and self.ANTILOPAY_PROJECT_ID is not None + ) + + def get_antilopay_display_name(self) -> str: + name = (self.ANTILOPAY_DISPLAY_NAME or '').strip() + return name if name else 'Antilopay' + + def get_antilopay_display_name_html(self) -> str: + return html.escape(self.get_antilopay_display_name()) + + def is_antilopay_sbp_enabled(self) -> bool: + return self.ANTILOPAY_SBP_ENABLED and self.is_antilopay_enabled() + + def get_antilopay_sbp_display_name(self) -> str: + name = (self.ANTILOPAY_SBP_DISPLAY_NAME or '').strip() + return name or 'СБП (Antilopay)' + + def get_antilopay_sbp_display_name_html(self) -> str: + return html.escape(self.get_antilopay_sbp_display_name()) + + def is_antilopay_card_enabled(self) -> bool: + return self.ANTILOPAY_CARD_ENABLED and self.is_antilopay_enabled() + + def get_antilopay_card_display_name(self) -> str: + name = (self.ANTILOPAY_CARD_DISPLAY_NAME or '').strip() + return name or 'Карта (Antilopay)' + + def get_antilopay_card_display_name_html(self) -> str: + return html.escape(self.get_antilopay_card_display_name()) + + def is_antilopay_sberpay_enabled(self) -> bool: + return self.ANTILOPAY_SBERPAY_ENABLED and self.is_antilopay_enabled() + + def get_antilopay_sberpay_display_name(self) -> str: + name = (self.ANTILOPAY_SBERPAY_DISPLAY_NAME or '').strip() + return name or 'SberPay (Antilopay)' + + def get_antilopay_sberpay_display_name_html(self) -> str: + return html.escape(self.get_antilopay_sberpay_display_name()) + + def is_jupiter_enabled(self) -> bool: + return self.JUPITER_ENABLED and self.JUPITER_TOKEN is not None and self.JUPITER_SECRET is not None + + def get_jupiter_display_name(self) -> str: + name = (self.JUPITER_DISPLAY_NAME or '').strip() + return name if name else 'Jupiter' + + def get_jupiter_display_name_html(self) -> str: + return html.escape(self.get_jupiter_display_name()) + + def is_jupiter_sbp_enabled(self) -> bool: + return self.JUPITER_SBP_ENABLED and self.is_jupiter_enabled() + + def get_jupiter_sbp_display_name(self) -> str: + name = (self.JUPITER_SBP_DISPLAY_NAME or '').strip() + return name or 'СБП (Jupiter)' + + def get_jupiter_sbp_display_name_html(self) -> str: + return html.escape(self.get_jupiter_sbp_display_name()) + + def is_donut_enabled(self) -> bool: + return self.DONUT_ENABLED and self.DONUT_TOKEN is not None and self.DONUT_SECRET is not None + + def get_donut_display_name(self) -> str: + name = (self.DONUT_DISPLAY_NAME or '').strip() + return name if name else 'Donut' + + def get_donut_display_name_html(self) -> str: + return html.escape(self.get_donut_display_name()) + + def is_donut_card_enabled(self) -> bool: + return self.DONUT_CARD_ENABLED and self.is_donut_enabled() + + def get_donut_card_display_name(self) -> str: + name = (self.DONUT_CARD_DISPLAY_NAME or '').strip() + return name or 'Карта (Donut)' + + def get_donut_card_display_name_html(self) -> str: + return html.escape(self.get_donut_card_display_name()) + + def is_donut_sbp_enabled(self) -> bool: + return self.DONUT_SBP_ENABLED and self.is_donut_enabled() + + def get_donut_sbp_display_name(self) -> str: + name = (self.DONUT_SBP_DISPLAY_NAME or '').strip() + return name or 'СБП (Donut)' + + def get_donut_sbp_display_name_html(self) -> str: + return html.escape(self.get_donut_sbp_display_name()) + + def is_donut_sbp_qr_enabled(self) -> bool: + return self.DONUT_SBP_QR_ENABLED and self.is_donut_enabled() + + def get_donut_sbp_qr_display_name(self) -> str: + name = (self.DONUT_SBP_QR_DISPLAY_NAME or '').strip() + return name or 'СБП QR (Donut)' + + def get_donut_sbp_qr_display_name_html(self) -> str: + return html.escape(self.get_donut_sbp_qr_display_name()) + + def is_lava_enabled(self) -> bool: + return ( + self.LAVA_ENABLED + and self.LAVA_SHOP_ID is not None + and self.LAVA_SECRET_KEY is not None + and self.LAVA_WEBHOOK_SECRET is not None + ) + + def get_lava_display_name(self) -> str: + name = (self.LAVA_DISPLAY_NAME or '').strip() + return name if name else 'Lava' + + def get_lava_display_name_html(self) -> str: + return html.escape(self.get_lava_display_name()) + + def is_lava_card_enabled(self) -> bool: + return self.LAVA_CARD_ENABLED and self.is_lava_enabled() + + def get_lava_card_display_name(self) -> str: + name = (self.LAVA_CARD_DISPLAY_NAME or '').strip() + return name or 'Карта (Lava)' + + def get_lava_card_display_name_html(self) -> str: + return html.escape(self.get_lava_card_display_name()) + + def is_lava_sbp_enabled(self) -> bool: + return self.LAVA_SBP_ENABLED and self.is_lava_enabled() + + def get_lava_sbp_display_name(self) -> str: + name = (self.LAVA_SBP_DISPLAY_NAME or '').strip() + return name or 'СБП (Lava)' + + def get_lava_sbp_display_name_html(self) -> str: + return html.escape(self.get_lava_sbp_display_name()) + + def is_etoplatezhi_enabled(self) -> bool: + return ( + self.ETOPLATEZHI_ENABLED + and self.ETOPLATEZHI_PROJECT_ID is not None + and self.ETOPLATEZHI_SECRET_KEY is not None + ) + + def get_etoplatezhi_display_name(self) -> str: + name = (self.ETOPLATEZHI_DISPLAY_NAME or '').strip() + return name if name else 'Etoplatezhi' + + def get_etoplatezhi_display_name_html(self) -> str: + return html.escape(self.get_etoplatezhi_display_name()) + + def is_etoplatezhi_sbp_enabled(self) -> bool: + return self.ETOPLATEZHI_SBP_ENABLED and self.is_etoplatezhi_enabled() + + def get_etoplatezhi_sbp_display_name(self) -> str: + name = (self.ETOPLATEZHI_SBP_DISPLAY_NAME or '').strip() + return name or 'СБП (Etoplatezhi)' + + def get_etoplatezhi_sbp_display_name_html(self) -> str: + return html.escape(self.get_etoplatezhi_sbp_display_name()) + + def is_etoplatezhi_card_enabled(self) -> bool: + return self.ETOPLATEZHI_CARD_ENABLED and self.is_etoplatezhi_enabled() + + def get_etoplatezhi_card_display_name(self) -> str: + name = (self.ETOPLATEZHI_CARD_DISPLAY_NAME or '').strip() + return name or 'Карта (Etoplatezhi)' + + def get_etoplatezhi_card_display_name_html(self) -> str: + return html.escape(self.get_etoplatezhi_card_display_name()) + def is_kassa_ai_sbp_enabled(self) -> bool: return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled() diff --git a/app/database/crud/antilopay.py b/app/database/crud/antilopay.py new file mode 100644 index 00000000..535dec87 --- /dev/null +++ b/app/database/crud/antilopay.py @@ -0,0 +1,159 @@ +"""CRUD операции для платежей Antilopay.""" + +from datetime import UTC, datetime + +import structlog +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import AntilopayPayment + + +logger = structlog.get_logger(__name__) + + +async def create_antilopay_payment( + db: AsyncSession, + *, + user_id: int | None, + order_id: str, + amount_kopeks: int, + currency: str = 'RUB', + description: str | None = None, + payment_url: str | None = None, + payment_method: str | None = None, + antilopay_payment_id: str | None = None, + expires_at: datetime | None = None, + metadata_json: dict | None = None, +) -> AntilopayPayment: + """Создает запись о платеже Antilopay.""" + payment = AntilopayPayment( + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=payment_method, + antilopay_payment_id=antilopay_payment_id, + expires_at=expires_at, + metadata_json=metadata_json, + status='pending', + is_paid=False, + ) + db.add(payment) + await db.commit() + await db.refresh(payment) + logger.info('Создан платеж Antilopay', order_id=order_id, user_id=user_id) + return payment + + +async def get_antilopay_payment_by_order_id(db: AsyncSession, order_id: str) -> AntilopayPayment | None: + """Получает платеж по order_id (internal).""" + result = await db.execute(select(AntilopayPayment).where(AntilopayPayment.order_id == order_id)) + return result.scalar_one_or_none() + + +async def get_antilopay_payment_by_invoice_id(db: AsyncSession, antilopay_payment_id: str) -> AntilopayPayment | None: + """Получает платеж по ID от Antilopay.""" + result = await db.execute( + select(AntilopayPayment).where(AntilopayPayment.antilopay_payment_id == antilopay_payment_id) + ) + return result.scalar_one_or_none() + + +async def get_antilopay_payment_by_id(db: AsyncSession, payment_id: int) -> AntilopayPayment | None: + """Получает платеж по ID.""" + result = await db.execute(select(AntilopayPayment).where(AntilopayPayment.id == payment_id)) + return result.scalar_one_or_none() + + +async def get_antilopay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> AntilopayPayment | None: + """Получает платеж по ID с блокировкой FOR UPDATE.""" + result = await db.execute( + select(AntilopayPayment) + .where(AntilopayPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return result.scalar_one_or_none() + + +async def update_antilopay_payment_status( + db: AsyncSession, + payment: AntilopayPayment, + *, + status: str, + is_paid: bool | None = None, + antilopay_payment_id: str | None = None, + payment_method: str | None = None, + callback_payload: dict | None = None, + transaction_id: int | None = None, +) -> AntilopayPayment: + """Обновляет статус платежа.""" + payment.status = status + payment.updated_at = datetime.now(UTC) + + if is_paid is not None: + payment.is_paid = is_paid + if is_paid: + payment.paid_at = datetime.now(UTC) + if antilopay_payment_id is not None: + payment.antilopay_payment_id = antilopay_payment_id + if payment_method is not None: + payment.payment_method = payment_method + if callback_payload is not None: + payment.callback_payload = callback_payload + if transaction_id is not None: + payment.transaction_id = transaction_id + + await db.commit() + await db.refresh(payment) + logger.info( + 'Обновлен статус платежа Antilopay', + order_id=payment.order_id, + status=status, + is_paid=payment.is_paid, + ) + return payment + + +async def get_pending_antilopay_payments(db: AsyncSession, user_id: int) -> list[AntilopayPayment]: + """Получает незавершенные платежи пользователя.""" + result = await db.execute( + select(AntilopayPayment).where( + AntilopayPayment.user_id == user_id, + AntilopayPayment.status == 'pending', + AntilopayPayment.is_paid == False, + ) + ) + return list(result.scalars().all()) + + +async def get_expired_pending_antilopay_payments( + db: AsyncSession, +) -> list[AntilopayPayment]: + """Получает просроченные платежи в статусе pending.""" + now = datetime.now(UTC) + result = await db.execute( + select(AntilopayPayment).where( + AntilopayPayment.status == 'pending', + AntilopayPayment.is_paid == False, + AntilopayPayment.expires_at < now, + ) + ) + return list(result.scalars().all()) + + +async def link_antilopay_payment_to_transaction( + db: AsyncSession, + *, + payment: AntilopayPayment, + transaction_id: int, +) -> AntilopayPayment: + """Связывает платеж с транзакцией.""" + payment.transaction_id = transaction_id + payment.updated_at = datetime.now(UTC) + await db.flush() + await db.refresh(payment) + return payment diff --git a/app/database/crud/apple_iap.py b/app/database/crud/apple_iap.py new file mode 100644 index 00000000..b2c898a5 --- /dev/null +++ b/app/database/crud/apple_iap.py @@ -0,0 +1,83 @@ +from datetime import UTC, datetime + +import structlog +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import AppleTransaction + + +logger = structlog.get_logger(__name__) + + +async def create_apple_transaction( + db: AsyncSession, + user_id: int, + transaction_id: str, + product_id: str, + bundle_id: str, + amount_kopeks: int, + environment: str, + original_transaction_id: str | None = None, + transaction_id_fk: int | None = None, +) -> AppleTransaction: + apple_txn = AppleTransaction( + user_id=user_id, + transaction_id=transaction_id, + original_transaction_id=original_transaction_id, + product_id=product_id, + bundle_id=bundle_id, + amount_kopeks=amount_kopeks, + environment=environment, + status='verified', + is_paid=True, + paid_at=datetime.now(UTC), + transaction_id_fk=transaction_id_fk, + ) + + db.add(apple_txn) + await db.flush() + await db.refresh(apple_txn) + + logger.info( + 'Создана Apple транзакция', + transaction_id=transaction_id, + product_id=product_id, + amount_kopeks=amount_kopeks, + user_id=user_id, + ) + return apple_txn + + +async def get_apple_transaction_by_transaction_id(db: AsyncSession, transaction_id: str) -> AppleTransaction | None: + result = await db.execute(select(AppleTransaction).where(AppleTransaction.transaction_id == transaction_id)) + return result.scalar_one_or_none() + + +async def get_apple_transaction_by_transaction_id_for_update( + db: AsyncSession, transaction_id: str +) -> AppleTransaction | None: + """Get apple transaction with FOR UPDATE lock for safe concurrent access.""" + result = await db.execute( + select(AppleTransaction).where(AppleTransaction.transaction_id == transaction_id).with_for_update() + ) + return result.scalar_one_or_none() + + +async def mark_apple_transaction_refunded(db: AsyncSession, transaction_id: str) -> AppleTransaction | None: + """Mark an Apple transaction as refunded. Returns the transaction or None if not found.""" + apple_txn = await get_apple_transaction_by_transaction_id(db, transaction_id) + if not apple_txn: + return None + + apple_txn.status = 'refunded' + apple_txn.refunded_at = datetime.now(UTC) + await db.flush() + await db.refresh(apple_txn) + + logger.info( + 'Apple транзакция помечена как возврат', + transaction_id=transaction_id, + user_id=apple_txn.user_id, + ) + return apple_txn diff --git a/app/database/crud/donut.py b/app/database/crud/donut.py new file mode 100644 index 00000000..97073140 --- /dev/null +++ b/app/database/crud/donut.py @@ -0,0 +1,155 @@ +"""CRUD операции для платежей Donut (Donut P2P).""" + +from datetime import UTC, datetime + +import structlog +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import DonutPayment + + +logger = structlog.get_logger(__name__) + + +async def create_donut_payment( + db: AsyncSession, + *, + user_id: int | None, + order_id: str, + amount_kopeks: int, + currency: str = 'RUB', + description: str | None = None, + payment_url: str | None = None, + payment_method: str | None = None, + donut_transaction_id: str | None = None, + expires_at: datetime | None = None, + metadata_json: dict | None = None, +) -> DonutPayment: + """Создаёт запись о платеже Donut.""" + payment = DonutPayment( + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=payment_method, + donut_transaction_id=donut_transaction_id, + expires_at=expires_at, + metadata_json=metadata_json, + status='pending', + is_paid=False, + ) + db.add(payment) + await db.commit() + await db.refresh(payment) + logger.info('Создан платеж Donut', order_id=order_id, user_id=user_id) + return payment + + +async def get_donut_payment_by_order_id(db: AsyncSession, order_id: str) -> DonutPayment | None: + """Получает платеж по order_id (internal).""" + result = await db.execute(select(DonutPayment).where(DonutPayment.order_id == order_id)) + return result.scalar_one_or_none() + + +async def get_donut_payment_by_invoice_id(db: AsyncSession, donut_transaction_id: str) -> DonutPayment | None: + """Получает платёж по transaction_id, выданному Donut.""" + result = await db.execute(select(DonutPayment).where(DonutPayment.donut_transaction_id == donut_transaction_id)) + return result.scalar_one_or_none() + + +async def get_donut_payment_by_id(db: AsyncSession, payment_id: int) -> DonutPayment | None: + """Получает платеж по локальному ID.""" + result = await db.execute(select(DonutPayment).where(DonutPayment.id == payment_id)) + return result.scalar_one_or_none() + + +async def get_donut_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> DonutPayment | None: + """Получает платёж с блокировкой FOR UPDATE.""" + result = await db.execute( + select(DonutPayment) + .where(DonutPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return result.scalar_one_or_none() + + +async def update_donut_payment_status( + db: AsyncSession, + payment: DonutPayment, + *, + status: str, + is_paid: bool | None = None, + donut_transaction_id: str | None = None, + payment_method: str | None = None, + callback_payload: dict | None = None, + transaction_id: int | None = None, +) -> DonutPayment: + """Обновляет статус платежа.""" + payment.status = status + payment.updated_at = datetime.now(UTC) + + if is_paid is not None: + payment.is_paid = is_paid + if is_paid: + payment.paid_at = datetime.now(UTC) + if donut_transaction_id is not None: + payment.donut_transaction_id = donut_transaction_id + if payment_method is not None: + payment.payment_method = payment_method + if callback_payload is not None: + payment.callback_payload = callback_payload + if transaction_id is not None: + payment.transaction_id = transaction_id + + await db.commit() + await db.refresh(payment) + logger.info( + 'Обновлён статус платежа Donut', + order_id=payment.order_id, + status=status, + is_paid=payment.is_paid, + ) + return payment + + +async def get_pending_donut_payments(db: AsyncSession, user_id: int) -> list[DonutPayment]: + """Возвращает незавершённые платежи пользователя.""" + result = await db.execute( + select(DonutPayment).where( + DonutPayment.user_id == user_id, + DonutPayment.status == 'pending', + DonutPayment.is_paid == False, + ) + ) + return list(result.scalars().all()) + + +async def get_expired_pending_donut_payments(db: AsyncSession) -> list[DonutPayment]: + """Возвращает просроченные платежи в статусе pending.""" + now = datetime.now(UTC) + result = await db.execute( + select(DonutPayment).where( + DonutPayment.status == 'pending', + DonutPayment.is_paid == False, + DonutPayment.expires_at < now, + ) + ) + return list(result.scalars().all()) + + +async def link_donut_payment_to_transaction( + db: AsyncSession, + *, + payment: DonutPayment, + transaction_id: int, +) -> DonutPayment: + """Связывает платёж с транзакцией.""" + payment.transaction_id = transaction_id + payment.updated_at = datetime.now(UTC) + await db.flush() + await db.refresh(payment) + return payment diff --git a/app/database/crud/etoplatezhi.py b/app/database/crud/etoplatezhi.py new file mode 100644 index 00000000..3ada81b9 --- /dev/null +++ b/app/database/crud/etoplatezhi.py @@ -0,0 +1,161 @@ +"""CRUD операции для платежей Etoplatezhi.""" + +from datetime import UTC, datetime + +import structlog +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import EtoplatezhiPayment + + +logger = structlog.get_logger(__name__) + + +async def create_etoplatezhi_payment( + db: AsyncSession, + *, + user_id: int | None, + order_id: str, + amount_kopeks: int, + currency: str = 'RUB', + description: str | None = None, + payment_url: str | None = None, + payment_method: str | None = None, + etoplatezhi_payment_id: str | None = None, + expires_at: datetime | None = None, + metadata_json: dict | None = None, +) -> EtoplatezhiPayment: + """Создает запись о платеже Etoplatezhi.""" + payment = EtoplatezhiPayment( + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=payment_method, + etoplatezhi_payment_id=etoplatezhi_payment_id, + expires_at=expires_at, + metadata_json=metadata_json, + status='pending', + is_paid=False, + ) + db.add(payment) + await db.commit() + await db.refresh(payment) + logger.info('Создан платеж Etoplatezhi', order_id=order_id, user_id=user_id) + return payment + + +async def get_etoplatezhi_payment_by_order_id(db: AsyncSession, order_id: str) -> EtoplatezhiPayment | None: + """Получает платеж по order_id (internal).""" + result = await db.execute(select(EtoplatezhiPayment).where(EtoplatezhiPayment.order_id == order_id)) + return result.scalar_one_or_none() + + +async def get_etoplatezhi_payment_by_invoice_id( + db: AsyncSession, etoplatezhi_payment_id: str +) -> EtoplatezhiPayment | None: + """Получает платеж по ID от Etoplatezhi.""" + result = await db.execute( + select(EtoplatezhiPayment).where(EtoplatezhiPayment.etoplatezhi_payment_id == etoplatezhi_payment_id) + ) + return result.scalar_one_or_none() + + +async def get_etoplatezhi_payment_by_id(db: AsyncSession, payment_id: int) -> EtoplatezhiPayment | None: + """Получает платеж по ID.""" + result = await db.execute(select(EtoplatezhiPayment).where(EtoplatezhiPayment.id == payment_id)) + return result.scalar_one_or_none() + + +async def get_etoplatezhi_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> EtoplatezhiPayment | None: + """Получает платеж по ID с блокировкой FOR UPDATE.""" + result = await db.execute( + select(EtoplatezhiPayment) + .where(EtoplatezhiPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return result.scalar_one_or_none() + + +async def update_etoplatezhi_payment_status( + db: AsyncSession, + payment: EtoplatezhiPayment, + *, + status: str, + is_paid: bool | None = None, + etoplatezhi_payment_id: str | None = None, + payment_method: str | None = None, + callback_payload: dict | None = None, + transaction_id: int | None = None, +) -> EtoplatezhiPayment: + """Обновляет статус платежа.""" + payment.status = status + payment.updated_at = datetime.now(UTC) + + if is_paid is not None: + payment.is_paid = is_paid + if is_paid: + payment.paid_at = datetime.now(UTC) + if etoplatezhi_payment_id is not None: + payment.etoplatezhi_payment_id = etoplatezhi_payment_id + if payment_method is not None: + payment.payment_method = payment_method + if callback_payload is not None: + payment.callback_payload = callback_payload + if transaction_id is not None: + payment.transaction_id = transaction_id + + await db.commit() + await db.refresh(payment) + logger.info( + 'Обновлен статус платежа Etoplatezhi', + order_id=payment.order_id, + status=status, + is_paid=payment.is_paid, + ) + return payment + + +async def get_pending_etoplatezhi_payments(db: AsyncSession, user_id: int) -> list[EtoplatezhiPayment]: + """Получает незавершенные платежи пользователя.""" + result = await db.execute( + select(EtoplatezhiPayment).where( + EtoplatezhiPayment.user_id == user_id, + EtoplatezhiPayment.status == 'pending', + EtoplatezhiPayment.is_paid == False, + ) + ) + return list(result.scalars().all()) + + +async def get_expired_pending_etoplatezhi_payments( + db: AsyncSession, +) -> list[EtoplatezhiPayment]: + """Получает просроченные платежи в статусе pending.""" + now = datetime.now(UTC) + result = await db.execute( + select(EtoplatezhiPayment).where( + EtoplatezhiPayment.status == 'pending', + EtoplatezhiPayment.is_paid == False, + EtoplatezhiPayment.expires_at < now, + ) + ) + return list(result.scalars().all()) + + +async def link_etoplatezhi_payment_to_transaction( + db: AsyncSession, + *, + payment: EtoplatezhiPayment, + transaction_id: int, +) -> EtoplatezhiPayment: + """Связывает платеж с транзакцией.""" + payment.transaction_id = transaction_id + payment.updated_at = datetime.now(UTC) + await db.flush() + await db.refresh(payment) + return payment diff --git a/app/database/crud/jupiter.py b/app/database/crud/jupiter.py new file mode 100644 index 00000000..d6f683e2 --- /dev/null +++ b/app/database/crud/jupiter.py @@ -0,0 +1,157 @@ +"""CRUD операции для платежей Jupiter (FPGate P2P v2.1).""" + +from datetime import UTC, datetime + +import structlog +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import JupiterPayment + + +logger = structlog.get_logger(__name__) + + +async def create_jupiter_payment( + db: AsyncSession, + *, + user_id: int | None, + order_id: str, + amount_kopeks: int, + currency: str = 'RUB', + description: str | None = None, + payment_url: str | None = None, + payment_method: str | None = None, + jupiter_transaction_id: str | None = None, + expires_at: datetime | None = None, + metadata_json: dict | None = None, +) -> JupiterPayment: + """Создаёт запись о платеже Jupiter.""" + payment = JupiterPayment( + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=payment_method, + jupiter_transaction_id=jupiter_transaction_id, + expires_at=expires_at, + metadata_json=metadata_json, + status='pending', + is_paid=False, + ) + db.add(payment) + await db.commit() + await db.refresh(payment) + logger.info('Создан платеж Jupiter', order_id=order_id, user_id=user_id) + return payment + + +async def get_jupiter_payment_by_order_id(db: AsyncSession, order_id: str) -> JupiterPayment | None: + """Получает платеж по order_id (internal).""" + result = await db.execute(select(JupiterPayment).where(JupiterPayment.order_id == order_id)) + return result.scalar_one_or_none() + + +async def get_jupiter_payment_by_invoice_id(db: AsyncSession, jupiter_transaction_id: str) -> JupiterPayment | None: + """Получает платёж по transaction_id, выданному Jupiter.""" + result = await db.execute( + select(JupiterPayment).where(JupiterPayment.jupiter_transaction_id == jupiter_transaction_id) + ) + return result.scalar_one_or_none() + + +async def get_jupiter_payment_by_id(db: AsyncSession, payment_id: int) -> JupiterPayment | None: + """Получает платеж по локальному ID.""" + result = await db.execute(select(JupiterPayment).where(JupiterPayment.id == payment_id)) + return result.scalar_one_or_none() + + +async def get_jupiter_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> JupiterPayment | None: + """Получает платёж с блокировкой FOR UPDATE.""" + result = await db.execute( + select(JupiterPayment) + .where(JupiterPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return result.scalar_one_or_none() + + +async def update_jupiter_payment_status( + db: AsyncSession, + payment: JupiterPayment, + *, + status: str, + is_paid: bool | None = None, + jupiter_transaction_id: str | None = None, + payment_method: str | None = None, + callback_payload: dict | None = None, + transaction_id: int | None = None, +) -> JupiterPayment: + """Обновляет статус платежа.""" + payment.status = status + payment.updated_at = datetime.now(UTC) + + if is_paid is not None: + payment.is_paid = is_paid + if is_paid: + payment.paid_at = datetime.now(UTC) + if jupiter_transaction_id is not None: + payment.jupiter_transaction_id = jupiter_transaction_id + if payment_method is not None: + payment.payment_method = payment_method + if callback_payload is not None: + payment.callback_payload = callback_payload + if transaction_id is not None: + payment.transaction_id = transaction_id + + await db.commit() + await db.refresh(payment) + logger.info( + 'Обновлён статус платежа Jupiter', + order_id=payment.order_id, + status=status, + is_paid=payment.is_paid, + ) + return payment + + +async def get_pending_jupiter_payments(db: AsyncSession, user_id: int) -> list[JupiterPayment]: + """Возвращает незавершённые платежи пользователя.""" + result = await db.execute( + select(JupiterPayment).where( + JupiterPayment.user_id == user_id, + JupiterPayment.status == 'pending', + JupiterPayment.is_paid == False, + ) + ) + return list(result.scalars().all()) + + +async def get_expired_pending_jupiter_payments(db: AsyncSession) -> list[JupiterPayment]: + """Возвращает просроченные платежи в статусе pending.""" + now = datetime.now(UTC) + result = await db.execute( + select(JupiterPayment).where( + JupiterPayment.status == 'pending', + JupiterPayment.is_paid == False, + JupiterPayment.expires_at < now, + ) + ) + return list(result.scalars().all()) + + +async def link_jupiter_payment_to_transaction( + db: AsyncSession, + *, + payment: JupiterPayment, + transaction_id: int, +) -> JupiterPayment: + """Связывает платёж с транзакцией.""" + payment.transaction_id = transaction_id + payment.updated_at = datetime.now(UTC) + await db.flush() + await db.refresh(payment) + return payment diff --git a/app/database/crud/lava.py b/app/database/crud/lava.py new file mode 100644 index 00000000..189490da --- /dev/null +++ b/app/database/crud/lava.py @@ -0,0 +1,155 @@ +"""CRUD операции для платежей Lava (Lava Business).""" + +from datetime import UTC, datetime + +import structlog +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import LavaPayment + + +logger = structlog.get_logger(__name__) + + +async def create_lava_payment( + db: AsyncSession, + *, + user_id: int | None, + order_id: str, + amount_kopeks: int, + currency: str = 'RUB', + description: str | None = None, + payment_url: str | None = None, + payment_method: str | None = None, + lava_invoice_id: str | None = None, + expires_at: datetime | None = None, + metadata_json: dict | None = None, +) -> LavaPayment: + """Создаёт запись о платеже Lava.""" + payment = LavaPayment( + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=payment_method, + lava_invoice_id=lava_invoice_id, + expires_at=expires_at, + metadata_json=metadata_json, + status='pending', + is_paid=False, + ) + db.add(payment) + await db.commit() + await db.refresh(payment) + logger.info('Создан платеж Lava', order_id=order_id, user_id=user_id) + return payment + + +async def get_lava_payment_by_order_id(db: AsyncSession, order_id: str) -> LavaPayment | None: + """Получает платёж по нашему orderId.""" + result = await db.execute(select(LavaPayment).where(LavaPayment.order_id == order_id)) + return result.scalar_one_or_none() + + +async def get_lava_payment_by_invoice_id(db: AsyncSession, lava_invoice_id: str) -> LavaPayment | None: + """Получает платёж по invoice_id, выданному Lava.""" + result = await db.execute(select(LavaPayment).where(LavaPayment.lava_invoice_id == lava_invoice_id)) + return result.scalar_one_or_none() + + +async def get_lava_payment_by_id(db: AsyncSession, payment_id: int) -> LavaPayment | None: + """Получает платёж по локальному ID.""" + result = await db.execute(select(LavaPayment).where(LavaPayment.id == payment_id)) + return result.scalar_one_or_none() + + +async def get_lava_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> LavaPayment | None: + """Получает платёж с FOR UPDATE-блокировкой.""" + result = await db.execute( + select(LavaPayment) + .where(LavaPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return result.scalar_one_or_none() + + +async def update_lava_payment_status( + db: AsyncSession, + payment: LavaPayment, + *, + status: str, + is_paid: bool | None = None, + lava_invoice_id: str | None = None, + payment_method: str | None = None, + callback_payload: dict | None = None, + transaction_id: int | None = None, +) -> LavaPayment: + """Обновляет статус платежа.""" + payment.status = status + payment.updated_at = datetime.now(UTC) + + if is_paid is not None: + payment.is_paid = is_paid + if is_paid: + payment.paid_at = datetime.now(UTC) + if lava_invoice_id is not None: + payment.lava_invoice_id = lava_invoice_id + if payment_method is not None: + payment.payment_method = payment_method + if callback_payload is not None: + payment.callback_payload = callback_payload + if transaction_id is not None: + payment.transaction_id = transaction_id + + await db.commit() + await db.refresh(payment) + logger.info( + 'Обновлён статус платежа Lava', + order_id=payment.order_id, + status=status, + is_paid=payment.is_paid, + ) + return payment + + +async def get_pending_lava_payments(db: AsyncSession, user_id: int) -> list[LavaPayment]: + """Возвращает незавершённые платежи пользователя.""" + result = await db.execute( + select(LavaPayment).where( + LavaPayment.user_id == user_id, + LavaPayment.status == 'pending', + LavaPayment.is_paid == False, + ) + ) + return list(result.scalars().all()) + + +async def get_expired_pending_lava_payments(db: AsyncSession) -> list[LavaPayment]: + """Возвращает просроченные платежи в статусе pending.""" + now = datetime.now(UTC) + result = await db.execute( + select(LavaPayment).where( + LavaPayment.status == 'pending', + LavaPayment.is_paid == False, + LavaPayment.expires_at < now, + ) + ) + return list(result.scalars().all()) + + +async def link_lava_payment_to_transaction( + db: AsyncSession, + *, + payment: LavaPayment, + transaction_id: int, +) -> LavaPayment: + """Связывает платёж с транзакцией.""" + payment.transaction_id = transaction_id + payment.updated_at = datetime.now(UTC) + await db.flush() + await db.refresh(payment) + return payment diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index a2d6f490..2b1a4821 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -31,6 +31,8 @@ REAL_PAYMENT_METHODS = [ PaymentMethod.PAYPEAR.value, PaymentMethod.OVERPAY.value, PaymentMethod.AURAPAY.value, + PaymentMethod.ETOPLATEZHI.value, + PaymentMethod.ANTILOPAY.value, ] diff --git a/app/database/models.py b/app/database/models.py index 8e710ceb..f40d5cae 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -162,10 +162,16 @@ class PaymentMethod(Enum): KASSA_AI = 'kassa_ai' RIOPAY = 'riopay' SEVERPAY = 'severpay' + APPLE_IAP = 'apple_iap' PAYPEAR = 'paypear' ROLLYPAY = 'rollypay' OVERPAY = 'overpay' AURAPAY = 'aurapay' + ETOPLATEZHI = 'etoplatezhi' + ANTILOPAY = 'antilopay' + JUPITER = 'jupiter' + DONUT = 'donut' + LAVA = 'lava' MANUAL = 'manual' BALANCE = 'balance' @@ -329,6 +335,41 @@ class CryptoBotPayment(Base): return f'' +class AppleTransaction(Base): + __tablename__ = 'apple_transactions' + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + + transaction_id = Column(String(64), unique=True, nullable=False, index=True) + original_transaction_id = Column(String(64), nullable=True, index=True) + product_id = Column(String(128), nullable=False) + bundle_id = Column(String(255), nullable=False) + amount_kopeks = Column(Integer, nullable=False) + environment = Column(String(16), nullable=False) + + status = Column(String(50), default='verified') + is_paid = Column(Boolean, default=True) + paid_at = Column(AwareDateTime(), nullable=True) + refunded_at = Column(AwareDateTime(), nullable=True) + + transaction_id_fk = Column(Integer, ForeignKey('transactions.id'), nullable=True) + metadata_json = Column(JSON, nullable=True) + + created_at = Column(AwareDateTime(), default=func.now()) + updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now()) + + user = relationship('User', backref='apple_transactions') + transaction = relationship('Transaction', backref='apple_transaction') + + @property + def amount_rubles(self) -> float: + return self.amount_kopeks / 100 + + def __repr__(self): + return f'' + + class HeleketPayment(Base): __tablename__ = 'heleket_payments' @@ -1130,6 +1171,318 @@ class AuraPayPayment(Base): return f'' +class EtoplatezhiPayment(Base): + """Платежи через Etoplatezhi (paymentpage.etoplatezhi.ru).""" + + __tablename__ = 'etoplatezhi_payments' + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True) + + # Идентификаторы + order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID + etoplatezhi_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Etoplatezhi + + # Суммы + amount_kopeks = Column(Integer, nullable=False) + currency = Column(String(10), nullable=False, default='RUB') + description = Column(Text, nullable=True) + + # Статусы + status = Column(String(32), nullable=False, default='pending') + is_paid = Column(Boolean, default=False) + + # Данные платежа + payment_url = Column(Text, nullable=True) + payment_method = Column(String(32), nullable=True) + + # Метаданные + metadata_json = Column(JSON, nullable=True) + callback_payload = Column(JSON, nullable=True) + + # Временные метки + paid_at = Column(AwareDateTime(), nullable=True) + expires_at = Column(AwareDateTime(), nullable=True) + created_at = Column(AwareDateTime(), default=func.now()) + updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now()) + + # Связь с транзакцией + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) + + # Relationships + user = relationship('User', backref='etoplatezhi_payments') + transaction = relationship('Transaction', backref='etoplatezhi_payment') + + @property + def amount_rubles(self) -> float: + return self.amount_kopeks / 100 + + @property + def is_pending(self) -> bool: + return self.status == 'pending' + + @property + def is_success(self) -> bool: + return self.status == 'success' and self.is_paid + + @property + def is_failed(self) -> bool: + return self.status in ['failed', 'expired', 'canceled', 'amount_mismatch'] + + def __repr__(self) -> str: # pragma: no cover - debug helper + return f'' + + +class AntilopayPayment(Base): + """Платежи через Antilopay (lk.antilopay.com).""" + + __tablename__ = 'antilopay_payments' + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True) + + # Идентификаторы + order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID + antilopay_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Antilopay (APAY...) + + # Суммы + amount_kopeks = Column(Integer, nullable=False) + currency = Column(String(10), nullable=False, default='RUB') + description = Column(Text, nullable=True) + + # Статусы + status = Column(String(32), nullable=False, default='pending') + is_paid = Column(Boolean, default=False) + + # Данные платежа + payment_url = Column(Text, nullable=True) + payment_method = Column(String(32), nullable=True) + + # Метаданные + metadata_json = Column(JSON, nullable=True) + callback_payload = Column(JSON, nullable=True) + + # Временные метки + paid_at = Column(AwareDateTime(), nullable=True) + expires_at = Column(AwareDateTime(), nullable=True) + created_at = Column(AwareDateTime(), default=func.now()) + updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now()) + + # Связь с транзакцией + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) + + # Relationships + user = relationship('User', backref='antilopay_payments') + transaction = relationship('Transaction', backref='antilopay_payment') + + @property + def amount_rubles(self) -> float: + return self.amount_kopeks / 100 + + @property + def is_pending(self) -> bool: + return self.status == 'pending' + + @property + def is_success(self) -> bool: + return self.status == 'success' and self.is_paid + + @property + def is_failed(self) -> bool: + return self.status in ['failed', 'expired', 'canceled', 'amount_mismatch'] + + def __repr__(self) -> str: # pragma: no cover - debug helper + return f'' + + +class JupiterPayment(Base): + """Платежи через Jupiter (FPGate P2P v2.1, app.juppiter.tech).""" + + __tablename__ = 'jupiter_payments' + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True) + + # Идентификаторы + order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID + jupiter_transaction_id = Column(String(128), unique=True, nullable=True, index=True) # transaction_id от Jupiter + + # Суммы + amount_kopeks = Column(Integer, nullable=False) + currency = Column(String(10), nullable=False, default='RUB') + description = Column(Text, nullable=True) + + # Статусы + status = Column(String(32), nullable=False, default='pending') + is_paid = Column(Boolean, default=False) + + # Данные платежа + payment_url = Column(Text, nullable=True) # qrcode_url из details (если есть) + payment_method = Column(String(32), nullable=True) # 'sbp' и т.д. + + # Метаданные + metadata_json = Column(JSON, nullable=True) + callback_payload = Column(JSON, nullable=True) + + # Временные метки + paid_at = Column(AwareDateTime(), nullable=True) + expires_at = Column(AwareDateTime(), nullable=True) + created_at = Column(AwareDateTime(), default=func.now()) + updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now()) + + # Связь с транзакцией + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) + + # Relationships + user = relationship('User', backref='jupiter_payments') + transaction = relationship('Transaction', backref='jupiter_payment') + + @property + def amount_rubles(self) -> float: + return self.amount_kopeks / 100 + + @property + def is_pending(self) -> bool: + return self.status == 'pending' + + @property + def is_success(self) -> bool: + return self.status == 'success' and self.is_paid + + @property + def is_failed(self) -> bool: + return self.status in ['failed', 'expired', 'cancelled', 'amount_mismatch', 'declined', 'error'] + + def __repr__(self) -> str: # pragma: no cover - debug helper + return f'' + + +class DonutPayment(Base): + """Платежи через Donut P2P (gw.donut.business).""" + + __tablename__ = 'donut_payments' + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True) + + # Идентификаторы + order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID + donut_transaction_id = Column(String(128), unique=True, nullable=True, index=True) # transaction_id от Donut + + # Суммы + amount_kopeks = Column(Integer, nullable=False) + currency = Column(String(10), nullable=False, default='RUB') + description = Column(Text, nullable=True) + + # Статусы + status = Column(String(32), nullable=False, default='pending') + is_paid = Column(Boolean, default=False) + + # Данные платежа + payment_url = Column(Text, nullable=True) # redirect_url или qrcode_url + payment_method = Column(String(32), nullable=True) # 'card', 'sbp', 'sbp_qr' + + # Метаданные + metadata_json = Column(JSON, nullable=True) + callback_payload = Column(JSON, nullable=True) + + # Временные метки + paid_at = Column(AwareDateTime(), nullable=True) + expires_at = Column(AwareDateTime(), nullable=True) + created_at = Column(AwareDateTime(), default=func.now()) + updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now()) + + # Связь с транзакцией + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) + + # Relationships + user = relationship('User', backref='donut_payments') + transaction = relationship('Transaction', backref='donut_payment') + + @property + def amount_rubles(self) -> float: + return self.amount_kopeks / 100 + + @property + def is_pending(self) -> bool: + return self.status in ('pending', 'created', 'processing') + + @property + def is_success(self) -> bool: + return self.status == 'success' and self.is_paid + + @property + def is_failed(self) -> bool: + return self.status in ['failed', 'expired', 'cancelled', 'amount_mismatch', 'error'] + + def __repr__(self) -> str: # pragma: no cover - debug helper + return f'' + + +class LavaPayment(Base): + """Платежи через Lava Business (gate.lava.ru).""" + + __tablename__ = 'lava_payments' + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True) + + # Идентификаторы + order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш orderId + lava_invoice_id = Column(String(128), unique=True, nullable=True, index=True) # invoice_id (UUID) от Lava + + # Суммы + amount_kopeks = Column(Integer, nullable=False) + currency = Column(String(10), nullable=False, default='RUB') + description = Column(Text, nullable=True) + + # Статусы + status = Column(String(32), nullable=False, default='pending') + is_paid = Column(Boolean, default=False) + + # Данные платежа + payment_url = Column(Text, nullable=True) + payment_method = Column(String(32), nullable=True) # 'card', 'sbp' и т.д. + + # Метаданные + metadata_json = Column(JSON, nullable=True) + callback_payload = Column(JSON, nullable=True) + + # Временные метки + paid_at = Column(AwareDateTime(), nullable=True) + expires_at = Column(AwareDateTime(), nullable=True) + created_at = Column(AwareDateTime(), default=func.now()) + updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now()) + + # Связь с транзакцией + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) + + # Relationships + user = relationship('User', backref='lava_payments') + transaction = relationship('Transaction', backref='lava_payment') + + @property + def amount_rubles(self) -> float: + return self.amount_kopeks / 100 + + @property + def is_pending(self) -> bool: + return self.status in ('pending', 'created', 'processing') + + @property + def is_success(self) -> bool: + return self.status == 'success' and self.is_paid + + @property + def is_failed(self) -> bool: + return self.status in ['failed', 'expired', 'cancel', 'cancelled', 'amount_mismatch', 'error'] + + def __repr__(self) -> str: # pragma: no cover - debug helper + return ( + f'' + ) + + class PromoGroup(Base): __tablename__ = 'promo_groups' @@ -1646,6 +1999,7 @@ class Subscription(Base): updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now()) last_webhook_update_at = Column(AwareDateTime(), nullable=True) + last_revoke_at = Column(AwareDateTime(), nullable=True) remnawave_short_uuid = Column(String(255), nullable=True) remnawave_uuid = Column(String(255), nullable=True) diff --git a/app/external/apple_iap.py b/app/external/apple_iap.py new file mode 100644 index 00000000..240844e0 --- /dev/null +++ b/app/external/apple_iap.py @@ -0,0 +1,411 @@ +"""Apple App Store Server API client for In-App Purchase verification and webhook handling.""" + +from __future__ import annotations + +import base64 +import datetime +import json +import time +from typing import Any + +import httpx +import jwt as pyjwt +import structlog +from cryptography import x509 +from cryptography.hazmat.primitives.asymmetric import ec, utils as asym_utils +from cryptography.hazmat.primitives.hashes import SHA256 +from cryptography.x509 import load_der_x509_certificate +from cryptography.x509.oid import ExtensionOID, ObjectIdentifier + +from app.config import settings + + +logger = structlog.get_logger(__name__) + +# Apple Root CA - G3 SHA-256 fingerprint for chain pinning +# https://www.apple.com/certificateauthority/ +APPLE_ROOT_CA_G3_SHA256 = bytes.fromhex('63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179') + +# Apple WWDR Intermediate Certificate OID +APPLE_WWDR_INTERMEDIATE_OID = ObjectIdentifier('1.2.840.113635.100.6.2.1') + +PRODUCTION_BASE_URL = 'https://api.storekit.itunes.apple.com' +SANDBOX_BASE_URL = 'https://api.storekit-sandbox.itunes.apple.com' + + +class AppleIAPService: + """Service for verifying Apple In-App Purchase transactions and handling notifications.""" + + def _get_base_url(self, environment: str | None = None) -> str: + env = environment or settings.APPLE_IAP_ENVIRONMENT + if env == 'Sandbox': + return SANDBOX_BASE_URL + return PRODUCTION_BASE_URL + + def _generate_jwt(self) -> str: + """Generate a fresh ES256 JWT for App Store Server API authentication. + + Apple recommends generating a new JWT for each request. + """ + private_key = settings.get_apple_iap_private_key() + if not private_key: + raise ValueError('Apple IAP private key is not configured') + + now = int(time.time()) + payload = { + 'iss': settings.APPLE_IAP_ISSUER_ID, + 'iat': now, + 'exp': now + 3600, + 'aud': 'appstoreconnect-v1', + 'bid': settings.APPLE_IAP_BUNDLE_ID, + } + headers = { + 'alg': 'ES256', + 'kid': settings.APPLE_IAP_KEY_ID, + 'typ': 'JWT', + } + + return pyjwt.encode(payload, private_key, algorithm='ES256', headers=headers) + + async def _fetch_transaction( + self, + transaction_id: str, + base_url: str, + ) -> httpx.Response | None: + """Send a GET request to Apple's transaction lookup endpoint.""" + url = f'{base_url}/inApps/v1/transactions/{transaction_id}' + token = self._generate_jwt() + + async with httpx.AsyncClient(timeout=30) as client: + try: + return await client.get( + url, + headers={'Authorization': f'Bearer {token}'}, + ) + except httpx.RequestError as e: + logger.error('Apple API request failed', error=str(e), transaction_id=transaction_id) + return None + + async def verify_transaction( + self, + transaction_id: str, + environment: str | None = None, + ) -> dict[str, Any] | None: + """Verify a transaction with Apple's App Store Server API. + + Follows Apple's recommendation: if the configured environment returns + a 4xx error, retries against the opposite environment. This ensures + Sandbox purchases made during App Review still verify when the server + is configured for Production. + """ + primary_url = self._get_base_url(environment) + # Determine fallback URL (opposite environment) + fallback_url = SANDBOX_BASE_URL if primary_url == PRODUCTION_BASE_URL else PRODUCTION_BASE_URL + + for attempt_url in (primary_url, fallback_url): + response = await self._fetch_transaction(transaction_id, attempt_url) + if response is None: + return None # network error -- don't retry + + if response.status_code == 200: + return self._parse_transaction_response(response, transaction_id) + + # 4xx on primary -> retry on fallback per Apple docs + if 400 <= response.status_code < 500 and attempt_url == primary_url: + logger.info( + 'Apple API returned 4xx on primary env, retrying fallback', + status=response.status_code, + primary=attempt_url, + fallback=fallback_url, + transaction_id=transaction_id, + ) + continue + + # Log the final failure + self._log_api_error(response, transaction_id) + return None + + return None + + def _parse_transaction_response( + self, + response: httpx.Response, + transaction_id: str, + ) -> dict[str, Any] | None: + """Extract and verify signedTransactionInfo from a 200 response.""" + data = response.json() + signed_transaction_info = data.get('signedTransactionInfo') + if signed_transaction_info: + decoded = self._verify_and_decode_jws(signed_transaction_info) + if decoded: + return decoded + logger.warning('Failed to verify signedTransactionInfo', transaction_id=transaction_id) + return None + logger.warning('No signedTransactionInfo in response', transaction_id=transaction_id) + return None + + @staticmethod + def _log_api_error(response: httpx.Response, transaction_id: str) -> None: + if response.status_code == 404: + logger.warning('Apple transaction not found', transaction_id=transaction_id) + elif response.status_code == 401: + logger.error('Apple API auth failed -- check key configuration') + elif response.status_code == 429: + logger.warning('Apple API rate limit exceeded') + else: + logger.error( + 'Apple API unexpected status', + status=response.status_code, + body=response.text[:500], + transaction_id=transaction_id, + ) + + def validate_transaction_info(self, txn_info: dict[str, Any], expected_product_id: str) -> str | None: + """Validate decoded transaction info fields. + + Returns None if valid, or an error message string. + """ + bundle_id = txn_info.get('bundleId') + if bundle_id != settings.APPLE_IAP_BUNDLE_ID: + return f'Bundle ID mismatch: {bundle_id}' + + product_id = txn_info.get('productId') + if product_id != expected_product_id: + return f'Product ID mismatch: {product_id} != {expected_product_id}' + + txn_type = txn_info.get('type') + if txn_type != 'Consumable': + return f'Unexpected transaction type: {txn_type}' + + if txn_info.get('revocationDate'): + return f'Transaction was revoked at {txn_info["revocationDate"]}' + + return None + + def _verify_and_decode_jws(self, jws_token: str) -> dict[str, Any] | None: + """Verify x5c certificate chain and ES256 signature, then decode the JWS payload. + + Returns the decoded payload dict, or None if verification fails. + Used for both outer notification payloads and inner signed data + (signedTransactionInfo, signedRenewalInfo). + """ + try: + parts = jws_token.split('.') + if len(parts) != 3: + logger.warning('Invalid JWS format: expected 3 parts') + return None + + # Decode header to get x5c chain + header_b64 = parts[0] + padding = 4 - len(header_b64) % 4 + if padding != 4: + header_b64 += '=' * padding + header_json = base64.urlsafe_b64decode(header_b64) + header = json.loads(header_json) + + x5c_chain = header.get('x5c', []) + if not x5c_chain: + logger.warning('No x5c certificate chain in JWS header') + return None + + # Verify the certificate chain + if not self._verify_x5c_chain(x5c_chain): + logger.warning('x5c certificate chain verification failed') + return None + + # Verify the signature using the leaf certificate + leaf_cert_der = base64.b64decode(x5c_chain[0]) + leaf_cert = load_der_x509_certificate(leaf_cert_der) + public_key = leaf_cert.public_key() + + signing_input = f'{parts[0]}.{parts[1]}'.encode('ascii') + signature_b64 = parts[2] + sig_padding = 4 - len(signature_b64) % 4 + if sig_padding != 4: + signature_b64 += '=' * sig_padding + signature = base64.urlsafe_b64decode(signature_b64) + + # ES256 signatures from JWS are in raw (r||s) format, convert to DER + if len(signature) == 64: + r = int.from_bytes(signature[:32], 'big') + s = int.from_bytes(signature[32:], 'big') + signature = asym_utils.encode_dss_signature(r, s) + + public_key.verify(signature, signing_input, ec.ECDSA(SHA256())) + + # Signature valid -- decode payload + return self._decode_jws_payload(jws_token) + + except Exception as e: + logger.error('JWS verification failed', error=str(e), exc_info=True) + return None + + def verify_notification(self, signed_payload: str) -> dict[str, Any] | None: + """Verify and decode an App Store Server Notification V2 payload. + + Verifies the JWS x5c certificate chain, then returns the decoded payload. + Returns None if verification fails. + """ + return self._verify_and_decode_jws(signed_payload) + + def _verify_x5c_chain(self, x5c_chain: list[str]) -> bool: + """Verify the x5c certificate chain ends with an Apple Root CA.""" + try: + if len(x5c_chain) < 2: + logger.warning('x5c chain too short', length=len(x5c_chain)) + return False + + certs = [] + for cert_b64 in x5c_chain: + cert_der = base64.b64decode(cert_b64) + cert = load_der_x509_certificate(cert_der) + certs.append(cert) + + # Check certificate validity periods + now = datetime.datetime.now(datetime.UTC) + for i, cert in enumerate(certs): + if now < cert.not_valid_before_utc: + logger.warning('x5c cert not yet valid', index=i, not_before=str(cert.not_valid_before_utc)) + return False + if now > cert.not_valid_after_utc: + logger.warning('x5c cert expired', index=i, not_after=str(cert.not_valid_after_utc)) + return False + + # Pin the root (last) certificate by SHA-256 fingerprint + root_cert = certs[-1] + root_fingerprint = root_cert.fingerprint(SHA256()) + if root_fingerprint != APPLE_ROOT_CA_G3_SHA256: + logger.warning( + 'Root CA fingerprint mismatch -- not genuine Apple Root CA - G3', + got=root_fingerprint.hex(), + ) + return False + + # Verify each certificate is signed by the next one in the chain + for i in range(len(certs) - 1): + child = certs[i] + parent = certs[i + 1] + parent_public_key = parent.public_key() + parent_public_key.verify( + child.signature, + child.tbs_certificate_bytes, + ec.ECDSA(child.signature_hash_algorithm), + ) + + # FIX 3: Validate Apple WWDR intermediate OID + # The intermediate cert (index 1) must contain the Apple WWDR OID + # to ensure it is a genuine Apple WWDR intermediate certificate. + if len(certs) >= 2: + intermediate_cert = certs[1] + try: + # Check for the Apple WWDR OID in certificate extensions + found_apple_oid = False + for ext in intermediate_cert.extensions: + if ext.oid == ExtensionOID.CERTIFICATE_POLICIES: + for policy in ext.value: + if policy.policy_identifier == APPLE_WWDR_INTERMEDIATE_OID: + found_apple_oid = True + break + if found_apple_oid: + break + if not found_apple_oid: + logger.warning( + 'Intermediate cert missing Apple WWDR OID', + oid=str(APPLE_WWDR_INTERMEDIATE_OID), + ) + return False + except x509.ExtensionNotFound: + logger.warning('Intermediate cert has no certificate policies extension') + return False + + return True + + except Exception as e: + logger.error('x5c chain verification error', error=str(e)) + return False + + def _decode_jws_payload(self, jws_token: str) -> dict[str, Any] | None: + """Decode the payload from a JWS token without signature verification. + + Use only after the signature has already been verified. + """ + try: + parts = jws_token.split('.') + if len(parts) != 3: + return None + + payload_b64 = parts[1] + # Add base64url padding + padding = 4 - len(payload_b64) % 4 + if padding != 4: + payload_b64 += '=' * padding + + payload_json = base64.urlsafe_b64decode(payload_b64) + return json.loads(payload_json) + + except Exception as e: + logger.error('Failed to decode JWS payload', error=str(e)) + return None + + async def send_consumption_info( + self, + transaction_id: str, + customer_consented: bool, + consumption_status: int = 0, + delivery_status: int = 0, + lifetime_dollars_purchased: int = 0, + lifetime_dollars_refunded: int = 0, + platform: int = 1, + play_time: int = 0, + sample_content_provided: bool = False, + user_status: int = 0, + environment: str | None = None, + refund_preference: int | None = None, + ) -> bool: + """Send consumption information to Apple in response to CONSUMPTION_REQUEST. + + Must be sent within 12 hours of receiving the notification. + """ + base_url = self._get_base_url(environment) + url = f'{base_url}/inApps/v2/transactions/consumption/{transaction_id}' + token = self._generate_jwt() + + body: dict[str, Any] = { + 'customerConsented': customer_consented, + 'consumptionStatus': consumption_status, + 'deliveryStatus': delivery_status, + 'lifetimeDollarsPurchased': lifetime_dollars_purchased, + 'lifetimeDollarsRefunded': lifetime_dollars_refunded, + 'platform': platform, + 'playTime': play_time, + 'sampleContentProvided': sample_content_provided, + 'userStatus': user_status, + } + if refund_preference is not None: + body['refundPreference'] = refund_preference + + async with httpx.AsyncClient(timeout=30) as client: + try: + response = await client.put( + url, + json=body, + headers={ + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json', + }, + ) + except httpx.RequestError as e: + logger.error('Apple consumption API request failed', error=str(e)) + return False + + if response.status_code == 202: + logger.info('Consumption info sent to Apple', transaction_id=transaction_id) + return True + + logger.error( + 'Apple consumption API error', + status=response.status_code, + body=response.text[:500], + transaction_id=transaction_id, + ) + return False diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py index 0d0a46d9..d9446456 100644 --- a/app/external/webhook_server.py +++ b/app/external/webhook_server.py @@ -57,6 +57,9 @@ class WebhookServer: self.app.router.add_get('/health', self._health_check) + if settings.is_apple_iap_enabled(): + self.app.router.add_post(settings.APPLE_IAP_WEBHOOK_PATH, self._apple_iap_webhook_handler) + self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler) if settings.is_mulenpay_enabled(): self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler) @@ -64,6 +67,8 @@ class WebhookServer: self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler) if settings.is_freekassa_enabled(): self.app.router.add_options(settings.FREEKASSA_WEBHOOK_PATH, self._options_handler) + if settings.is_apple_iap_enabled(): + self.app.router.add_options(settings.APPLE_IAP_WEBHOOK_PATH, self._options_handler) logger.info('Webhook сервер настроен:') logger.info('Tribute webhook: POST', TRIBUTE_WEBHOOK_PATH=settings.TRIBUTE_WEBHOOK_PATH) @@ -76,6 +81,8 @@ class WebhookServer: logger.info('CryptoBot webhook: POST', CRYPTOBOT_WEBHOOK_PATH=settings.CRYPTOBOT_WEBHOOK_PATH) if settings.is_freekassa_enabled(): logger.info('Freekassa webhook: POST', FREEKASSA_WEBHOOK_PATH=settings.FREEKASSA_WEBHOOK_PATH) + if settings.is_apple_iap_enabled(): + logger.info('Apple IAP webhook: POST', APPLE_IAP_WEBHOOK_PATH=settings.APPLE_IAP_WEBHOOK_PATH) logger.info(' - Health check: GET /health') return self.app @@ -491,3 +498,335 @@ class WebhookServer: except Exception as e: logger.error('Критическая ошибка обработки Freekassa webhook', error=e, exc_info=True) return web.Response(text='NO', status=500) + + async def _apple_iap_webhook_handler(self, request: web.Request) -> web.Response: + """Handle Apple App Store Server Notifications V2.""" + try: + logger.info('Получен Apple IAP webhook', method=request.method, path=request.path) + + raw_body = await request.read() + if not raw_body: + logger.warning('Пустой Apple IAP webhook') + return web.Response(status=400) + + try: + body = json.loads(raw_body.decode('utf-8')) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + logger.error('Ошибка парсинга Apple IAP webhook', error=e) + return web.Response(status=400) + + signed_payload = body.get('signedPayload') + if not signed_payload: + logger.warning('No signedPayload in Apple webhook') + return web.Response(status=400) + + # Verify and decode the notification + from app.external.apple_iap import AppleIAPService + + apple_service = AppleIAPService() + notification = apple_service.verify_notification(signed_payload) + if not notification: + logger.warning('Apple webhook signature verification failed') + return web.Response(status=403) + + notification_type = notification.get('notificationType', '') + subtype = notification.get('subtype', '') + + # Verify notification environment matches our config + # FIX 11: removed dead initial assignment of expected_envs + notif_env = notification.get('data', {}).get('environment', '') + if settings.APPLE_IAP_ENVIRONMENT == 'Production': + expected_envs = {'Production', 'Sandbox'} # Sandbox for App Review + else: + expected_envs = {'Sandbox'} + if notif_env and notif_env not in expected_envs: + logger.warning( + 'Apple webhook environment mismatch', + expected=settings.APPLE_IAP_ENVIRONMENT, + received=notif_env, + ) + return web.Response(status=200) # ACK but ignore + + logger.info( + 'Apple notification received', + notification_type=notification_type, + subtype=subtype, + environment=notif_env, + ) + + # Handle notification types + if notification_type == 'TEST': + logger.info('Apple TEST notification received -- OK') + return web.Response(status=200) + + if notification_type == 'REFUND': + await self._handle_apple_refund(notification, apple_service) + return web.Response(status=200) + + if notification_type == 'REFUND_REVERSED': + await self._handle_apple_refund_reversed(notification) + return web.Response(status=200) + + if notification_type == 'CONSUMPTION_REQUEST': + await self._handle_apple_consumption_request(notification, apple_service) + return web.Response(status=200) + + if notification_type in ('ONE_TIME_CHARGE', 'REFUND_DECLINED'): + logger.info('Apple notification logged', notification_type=notification_type) + return web.Response(status=200) + + logger.info('Unhandled Apple notification type', notification_type=notification_type) + return web.Response(status=200) + + except Exception as e: + logger.error('Критическая ошибка обработки Apple IAP webhook', error=e, exc_info=True) + return web.Response(status=500) + + async def _handle_apple_refund(self, notification: dict, apple_service) -> None: + """Handle REFUND notification -- deduct credited balance.""" + try: + data = notification.get('data', {}) + signed_txn_info = data.get('signedTransactionInfo') + if not signed_txn_info: + logger.warning('No signedTransactionInfo in REFUND notification') + return + + txn_info = apple_service._verify_and_decode_jws(signed_txn_info) + if not txn_info: + logger.warning('Failed to verify REFUND transaction info') + return + + apple_txn_id = str(txn_info.get('transactionId') or '') + original_txn_id = str(txn_info.get('originalTransactionId') or '') + product_id = txn_info.get('productId', '') + + from app.database.crud.apple_iap import ( + mark_apple_transaction_refunded, + ) + from app.database.crud.user import lock_user_for_pricing + from app.database.database import AsyncSessionLocal + from app.database.models import PaymentMethod, TransactionType + + lookup_id = original_txn_id or apple_txn_id + + async with AsyncSessionLocal() as db: + from app.database.crud.apple_iap import get_apple_transaction_by_transaction_id_for_update + + apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, lookup_id) + if not apple_txn: + # Try the other ID + apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, apple_txn_id) + + if not apple_txn: + logger.warning( + 'Apple REFUND: transaction not found', + transaction_id=apple_txn_id, + original_transaction_id=original_txn_id, + ) + return + + if apple_txn.status == 'refunded': + logger.info('Apple REFUND: already refunded', transaction_id=lookup_id) + return + + if apple_txn.environment == 'Sandbox' and settings.APPLE_IAP_ENVIRONMENT == 'Production': + logger.info( + 'Apple REFUND: ignoring sandbox refund on production', + transaction_id=lookup_id, + user_id=apple_txn.user_id, + ) + return + + # FIX 6: Lock user row with FOR UPDATE before reading balance + # to prevent race condition in min() balance cap calculation + user = await lock_user_for_pricing(db, apple_txn.user_id) + if not user: + logger.error('Apple REFUND: user not found', user_id=apple_txn.user_id) + return + + # Cap deduction to current balance to prevent negative balance + refund_amount = min(apple_txn.amount_kopeks, user.balance_kopeks) + if refund_amount < apple_txn.amount_kopeks: + logger.warning( + 'Apple REFUND: partial balance deduction (user already spent funds)', + full_amount=apple_txn.amount_kopeks, + deducted=refund_amount, + user_balance=user.balance_kopeks, + user_id=user.id, + ) + + # Disable active subscriptions -- funds were spent and refunded + from app.database.crud.subscription import ( + deactivate_subscription, + get_active_subscriptions_by_user_id, + ) + + active_subs = await get_active_subscriptions_by_user_id(db, user.id) + for sub in active_subs: + await deactivate_subscription(db, sub, commit=False) + logger.warning( + 'Apple REFUND: disabled subscription due to insufficient balance', + subscription_id=sub.id, + user_id=user.id, + ) + + if refund_amount > 0: + from app.database.crud.user import subtract_user_balance + + await subtract_user_balance( + db=db, + user=user, + amount_kopeks=refund_amount, + description=f'Возврат Apple IAP: {product_id}', + create_transaction=True, + payment_method=PaymentMethod.APPLE_IAP, + transaction_type=TransactionType.REFUND, + commit=False, + ) + + await mark_apple_transaction_refunded(db, apple_txn.transaction_id) + await db.commit() + + logger.info( + 'Apple REFUND processed', + transaction_id=apple_txn.transaction_id, + amount_kopeks=apple_txn.amount_kopeks, + user_id=user.id, + ) + + except Exception as e: + logger.error('Error handling Apple REFUND', error=e, exc_info=True) + + async def _handle_apple_refund_reversed(self, notification: dict) -> None: + """Handle REFUND_REVERSED -- re-credit balance that was previously deducted.""" + try: + data = notification.get('data', {}) + signed_txn_info = data.get('signedTransactionInfo') + if not signed_txn_info: + logger.warning('No signedTransactionInfo in REFUND_REVERSED notification') + return + + from app.external.apple_iap import AppleIAPService + + apple_service = AppleIAPService() + txn_info = apple_service._verify_and_decode_jws(signed_txn_info) + if not txn_info: + logger.warning('Failed to verify REFUND_REVERSED transaction info') + return + + apple_txn_id = str(txn_info.get('transactionId') or '') + original_txn_id = str(txn_info.get('originalTransactionId') or '') + product_id = txn_info.get('productId', '') + + from app.database.crud.apple_iap import ( + get_apple_transaction_by_transaction_id_for_update, + ) + from app.database.crud.user import add_user_balance, get_user_by_id + from app.database.database import AsyncSessionLocal + from app.database.models import PaymentMethod + + lookup_id = original_txn_id or apple_txn_id + + async with AsyncSessionLocal() as db: + # FIX 7: Use FOR UPDATE lock on apple_transactions row + # before checking status to prevent idempotency race + apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, lookup_id) + if not apple_txn: + apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, apple_txn_id) + + if not apple_txn: + logger.warning( + 'Apple REFUND_REVERSED: transaction not found', + transaction_id=apple_txn_id, + ) + return + + if apple_txn.status != 'refunded': + logger.info( + 'Apple REFUND_REVERSED: transaction not in refunded state', + transaction_id=lookup_id, + status=apple_txn.status, + ) + return + + if apple_txn.environment == 'Sandbox' and settings.APPLE_IAP_ENVIRONMENT == 'Production': + logger.info( + 'Apple REFUND_REVERSED: ignoring sandbox on production', + transaction_id=lookup_id, + ) + return + + user = await get_user_by_id(db, apple_txn.user_id) + if not user: + logger.error('Apple REFUND_REVERSED: user not found', user_id=apple_txn.user_id) + return + + # Re-credit the balance + await add_user_balance( + db=db, + user=user, + amount_kopeks=apple_txn.amount_kopeks, + description=f'Отмена возврата Apple IAP: {product_id}', + payment_method=PaymentMethod.APPLE_IAP, + commit=False, + ) + + apple_txn.status = 'verified' + apple_txn.refunded_at = None + await db.commit() + + logger.info( + 'Apple REFUND_REVERSED processed -- balance re-credited', + transaction_id=lookup_id, + amount_kopeks=apple_txn.amount_kopeks, + user_id=user.id, + ) + + except Exception as e: + logger.error('Error handling Apple REFUND_REVERSED', error=e, exc_info=True) + + async def _handle_apple_consumption_request(self, notification: dict, apple_service) -> None: + """Handle CONSUMPTION_REQUEST -- send consumption info to Apple.""" + try: + data = notification.get('data', {}) + signed_txn_info = data.get('signedTransactionInfo') + if not signed_txn_info: + logger.warning('No signedTransactionInfo in CONSUMPTION_REQUEST') + return + + txn_info = apple_service._verify_and_decode_jws(signed_txn_info) + if not txn_info: + logger.warning('Failed to verify CONSUMPTION_REQUEST transaction info') + return + + apple_txn_id = str(txn_info.get('transactionId') or '') + environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT) + + from app.database.crud.apple_iap import get_apple_transaction_by_transaction_id + from app.database.database import AsyncSessionLocal + + async with AsyncSessionLocal() as db: + apple_txn = await get_apple_transaction_by_transaction_id(db, apple_txn_id) + + # Determine if balance was consumed (spent on subscriptions) + # consumptionStatus: 0 = undeclared, 1 = not consumed, 2 = partially consumed, 3 = fully consumed + consumption_status = 0 + if apple_txn and apple_txn.status == 'verified': + consumption_status = 3 # Balance was credited and likely spent + + # customerConsented must be false -- we cannot prompt the user + # in a server-to-server webhook. Apple accepts the response + # regardless, but the consumption data weight may be lower. + await apple_service.send_consumption_info( + transaction_id=apple_txn_id, + customer_consented=False, + consumption_status=consumption_status, + delivery_status=0, # 0 = delivered + platform=1, # 1 = Apple + environment=environment, + ) + + logger.info('Apple CONSUMPTION_REQUEST handled', transaction_id=apple_txn_id) + + except Exception as e: + logger.error('Error handling Apple CONSUMPTION_REQUEST', error=e, exc_info=True) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 80f90299..b9eccc4b 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -175,6 +175,7 @@ async def _build_notification_preview_message(language: str, notification_type: message = template.format( end_date=(now - timedelta(days=1)).strftime('%d.%m.%Y %H:%M'), price=price_30_days, + tariff_label='', ) keyboard = InlineKeyboardMarkup( inline_keyboard=[ @@ -213,6 +214,7 @@ async def _build_notification_preview_message(language: str, notification_type: percent=percent, expires_at=(now + timedelta(hours=valid_hours)).strftime('%d.%m.%Y %H:%M'), trigger_days=3, + tariff_label='', ) keyboard = InlineKeyboardMarkup( inline_keyboard=[ @@ -258,6 +260,7 @@ async def _build_notification_preview_message(language: str, notification_type: percent=percent, trigger_days=trigger_days, expires_at=(now + timedelta(hours=valid_hours)).strftime('%d.%m.%Y %H:%M'), + tariff_label='', ) keyboard = InlineKeyboardMarkup( inline_keyboard=[ diff --git a/app/handlers/balance/antilopay.py b/app/handlers/balance/antilopay.py new file mode 100644 index 00000000..1e699e6b --- /dev/null +++ b/app/handlers/balance/antilopay.py @@ -0,0 +1,315 @@ +"""Handler for Antilopay balance top-up.""" + +import html + +import structlog +from aiogram import types +from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.keyboards.inline import get_back_keyboard +from app.localization.texts import get_texts +from app.services.payment_service import PaymentService +from app.states import BalanceStates +from app.utils.decorators import error_handler + + +logger = structlog.get_logger(__name__) + + +def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None: + """Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None.""" + if not getattr(db_user, 'restriction_topup', False): + return None + + keyboard = [] + support_url = settings.get_support_contact_url() + if support_url: + keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +async def _create_antilopay_payment_and_respond( + message_or_callback, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + edit_message: bool = False, + payment_method_type: str | None = None, +): + """ + Common logic for creating Antilopay payment and sending response. + """ + texts = get_texts(db_user.language) + amount_rub = amount_kopeks / 100 + + # Create payment + payment_service = PaymentService() + + description = settings.PAYMENT_BALANCE_TEMPLATE.format( + service_name=settings.PAYMENT_SERVICE_NAME, + description='Пополнение баланса', + ) + + result = await payment_service.create_antilopay_payment( + db=db, + user_id=db_user.id, + amount_kopeks=amount_kopeks, + description=description, + email=getattr(db_user, 'email', None), + language=db_user.language, + payment_method_type=payment_method_type, + ) + + if not result: + error_text = texts.t( + 'PAYMENT_CREATE_ERROR', + 'Не удалось создать платёж. Попробуйте позже.', + ) + if edit_message: + await message_or_callback.edit_text( + error_text, + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + else: + await message_or_callback.answer( + error_text, + parse_mode='HTML', + ) + return + + payment_url = result.get('payment_url') + display_name = settings.get_antilopay_display_name() + + # Create keyboard with payment button + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t( + 'PAY_BUTTON', + '\U0001f4b3 Оплатить {amount}\u20bd', + ).format(amount=f'{amount_rub:.0f}'), + url=payment_url, + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'), + callback_data='menu_balance', + ) + ], + ] + ) + + response_text = texts.t( + 'ANTILOPAY_PAYMENT_CREATED', + '\U0001f4b3 Оплата через {name}\n\n' + 'Сумма: {amount}\u20bd\n\n' + 'Нажмите кнопку ниже для оплаты.\n' + 'После успешной оплаты баланс будет пополнен автоматически.', + ).format(name=display_name, amount=f'{amount_rub:.2f}') + + if edit_message: + await message_or_callback.edit_text( + response_text, + reply_markup=keyboard, + parse_mode='HTML', + ) + else: + await message_or_callback.answer( + response_text, + reply_markup=keyboard, + parse_mode='HTML', + ) + + logger.info('Antilopay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub) + + +@error_handler +async def process_antilopay_payment_amount( + message: types.Message, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + state: FSMContext, +): + """ + Process payment amount directly. + """ + texts = get_texts(db_user.language) + + restriction_kb = _check_topup_restriction(db_user, texts) + if restriction_kb: + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + await message.answer( + f'\U0001f6ab Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=restriction_kb, + ) + await state.clear() + return + + # Validate amount + min_amount = settings.ANTILOPAY_MIN_AMOUNT_KOPEKS + max_amount = settings.ANTILOPAY_MAX_AMOUNT_KOPEKS + + if amount_kopeks < min_amount: + await message.answer( + texts.t( + 'PAYMENT_AMOUNT_TOO_LOW', + 'Минимальная сумма пополнения: {min_amount}\u20bd', + ).format(min_amount=min_amount // 100), + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + return + + if amount_kopeks > max_amount: + await message.answer( + texts.t( + 'PAYMENT_AMOUNT_TOO_HIGH', + 'Максимальная сумма пополнения: {max_amount}\u20bd', + ).format(max_amount=max_amount // 100), + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + return + + data = await state.get_data() + payment_method = data.get('payment_method', 'antilopay') + # antilopay_sbp → 'sbp', antilopay_card → 'card', antilopay_sberpay → 'sberpay', antilopay → None + payment_method_type = _extract_service_type(payment_method) + + await state.clear() + + await _create_antilopay_payment_and_respond( + message_or_callback=message, + db_user=db_user, + db=db, + amount_kopeks=amount_kopeks, + edit_message=False, + payment_method_type=payment_method_type, + ) + + +ANTILOPAY_PAYMENT_METHODS = {'antilopay', 'antilopay_sbp', 'antilopay_card', 'antilopay_sberpay'} + +ANTILOPAY_SERVICE_MAP: dict[str, str | None] = { + 'antilopay': None, + 'antilopay_sbp': 'sbp', + 'antilopay_card': 'card', + 'antilopay_sberpay': 'sberpay', +} + + +def _extract_service_type(payment_method: str) -> str | None: + return ANTILOPAY_SERVICE_MAP.get(payment_method) + + +async def _start_antilopay_topup_impl( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + payment_method: str, +): + """Common logic for starting Antilopay top-up (generic / SBP / card / SberPay).""" + texts = get_texts(db_user.language) + + restriction_kb = _check_topup_restriction(db_user, texts) + if restriction_kb: + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + await callback.message.edit_text( + f'\U0001f6ab Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=restriction_kb, + ) + return + + await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data(payment_method=payment_method) + + min_amount = settings.ANTILOPAY_MIN_AMOUNT_KOPEKS // 100 + max_amount = settings.ANTILOPAY_MAX_AMOUNT_KOPEKS // 100 + + # Choose display name based on sub-method + if payment_method == 'antilopay_sbp': + display_name = settings.get_antilopay_sbp_display_name() + elif payment_method == 'antilopay_card': + display_name = settings.get_antilopay_card_display_name() + elif payment_method == 'antilopay_sberpay': + display_name = settings.get_antilopay_sberpay_display_name() + else: + display_name = settings.get_antilopay_display_name() + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'), + callback_data='menu_balance', + ) + ] + ] + ) + + await callback.message.edit_text( + texts.t( + 'ANTILOPAY_ENTER_AMOUNT', + '\U0001f4b3 Пополнение через {name}\n\n' + 'Введите сумму пополнения в рублях.\n\n' + 'Минимум: {min_amount}\u20bd\n' + 'Максимум: {max_amount}\u20bd', + ).format( + name=display_name, + min_amount=min_amount, + max_amount=f'{max_amount:,}'.replace(',', ' '), + ), + parse_mode='HTML', + reply_markup=keyboard, + ) + + +@error_handler +async def start_antilopay_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay') + + +@error_handler +async def start_antilopay_sbp_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay_sbp') + + +@error_handler +async def start_antilopay_card_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay_card') + + +@error_handler +async def start_antilopay_sberpay_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay_sberpay') diff --git a/app/handlers/balance/aurapay.py b/app/handlers/balance/aurapay.py index 1947cab7..3a7e42c3 100644 --- a/app/handlers/balance/aurapay.py +++ b/app/handlers/balance/aurapay.py @@ -39,6 +39,7 @@ async def _create_aurapay_payment_and_respond( db: AsyncSession, amount_kopeks: int, edit_message: bool = False, + payment_method_type: str | None = None, ): """ Common logic for creating AuraPay payment and sending response. @@ -61,6 +62,7 @@ async def _create_aurapay_payment_and_respond( description=description, email=getattr(db_user, 'email', None), language=db_user.language, + payment_method_type=payment_method_type, ) if not result: @@ -179,6 +181,11 @@ async def process_aurapay_payment_amount( ) return + data = await state.get_data() + payment_method = data.get('payment_method', 'aurapay') + # aurapay_sbp → 'sbp', aurapay_card → 'card', aurapay → None + payment_method_type = _extract_service_type(payment_method) + await state.clear() await _create_aurapay_payment_and_respond( @@ -187,19 +194,30 @@ async def process_aurapay_payment_amount( db=db, amount_kopeks=amount_kopeks, edit_message=False, + payment_method_type=payment_method_type, ) -@error_handler -async def start_aurapay_topup( +AURAPAY_PAYMENT_METHODS = {'aurapay', 'aurapay_sbp', 'aurapay_card'} + +AURAPAY_SERVICE_MAP: dict[str, str | None] = { + 'aurapay': None, + 'aurapay_sbp': 'sbp', + 'aurapay_card': 'card', +} + + +def _extract_service_type(payment_method: str) -> str | None: + return AURAPAY_SERVICE_MAP.get(payment_method) + + +async def _start_aurapay_topup_impl( callback: types.CallbackQuery, db_user: User, - db: AsyncSession, state: FSMContext, + payment_method: str, ): - """ - Start AuraPay top-up process - ask for amount. - """ + """Common logic for starting AuraPay top-up (generic / SBP / card).""" texts = get_texts(db_user.language) restriction_kb = _check_topup_restriction(db_user, texts) @@ -213,11 +231,18 @@ async def start_aurapay_topup( return await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method='aurapay') + await state.update_data(payment_method=payment_method) min_amount = settings.AURAPAY_MIN_AMOUNT_KOPEKS // 100 max_amount = settings.AURAPAY_MAX_AMOUNT_KOPEKS // 100 - display_name = settings.get_aurapay_display_name() + + # Choose display name based on sub-method + if payment_method == 'aurapay_sbp': + display_name = settings.get_aurapay_sbp_display_name() + elif payment_method == 'aurapay_card': + display_name = settings.get_aurapay_card_display_name() + else: + display_name = settings.get_aurapay_display_name() keyboard = InlineKeyboardMarkup( inline_keyboard=[ @@ -245,3 +270,33 @@ async def start_aurapay_topup( parse_mode='HTML', reply_markup=keyboard, ) + + +@error_handler +async def start_aurapay_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_aurapay_topup_impl(callback, db_user, state, 'aurapay') + + +@error_handler +async def start_aurapay_sbp_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_aurapay_topup_impl(callback, db_user, state, 'aurapay_sbp') + + +@error_handler +async def start_aurapay_card_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_aurapay_topup_impl(callback, db_user, state, 'aurapay_card') diff --git a/app/handlers/balance/donut.py b/app/handlers/balance/donut.py new file mode 100644 index 00000000..917e9a66 --- /dev/null +++ b/app/handlers/balance/donut.py @@ -0,0 +1,305 @@ +"""Handler for Donut balance top-up (Donut P2P).""" + +import html + +import structlog +from aiogram import types +from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.keyboards.inline import get_back_keyboard +from app.localization.texts import get_texts +from app.services.payment_service import PaymentService +from app.states import BalanceStates +from app.utils.decorators import error_handler + + +logger = structlog.get_logger(__name__) + + +DONUT_PAYMENT_METHODS = {'donut', 'donut_card', 'donut_sbp', 'donut_sbp_qr'} + +DONUT_SERVICE_MAP: dict[str, str | None] = { + 'donut': None, + 'donut_card': 'card', + 'donut_sbp': 'sbp', + 'donut_sbp_qr': 'sbp_qr', +} + + +def _extract_service_type(payment_method: str) -> str | None: + return DONUT_SERVICE_MAP.get(payment_method) + + +def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None: + """Проверяет ограничение на пополнение.""" + if not getattr(db_user, 'restriction_topup', False): + return None + + keyboard = [] + support_url = settings.get_support_contact_url() + if support_url: + keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def _get_display_name(payment_method: str) -> str: + if payment_method == 'donut_card': + return settings.get_donut_card_display_name() + if payment_method == 'donut_sbp': + return settings.get_donut_sbp_display_name() + if payment_method == 'donut_sbp_qr': + return settings.get_donut_sbp_qr_display_name() + return settings.get_donut_display_name() + + +async def _create_donut_payment_and_respond( + message_or_callback, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + edit_message: bool = False, + payment_method_type: str | None = None, + display_name: str | None = None, +): + """Создаёт платёж Donut и отправляет ссылку пользователю.""" + texts = get_texts(db_user.language) + amount_rub = amount_kopeks / 100 + + payment_service = PaymentService() + description = settings.PAYMENT_BALANCE_TEMPLATE.format( + service_name=settings.PAYMENT_SERVICE_NAME, + description='Пополнение баланса', + ) + + result = await payment_service.create_donut_payment( + db=db, + user_id=db_user.id, + amount_kopeks=amount_kopeks, + description=description, + email=getattr(db_user, 'email', None), + language=db_user.language, + payment_method_type=payment_method_type, + ) + + if not result: + error_text = texts.t( + 'PAYMENT_CREATE_ERROR', + 'Не удалось создать платёж. Попробуйте позже.', + ) + if edit_message: + await message_or_callback.edit_text( + error_text, + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + else: + await message_or_callback.answer(error_text, parse_mode='HTML') + return + + payment_url = result.get('payment_url') + name = display_name or settings.get_donut_display_name() + + pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}₽').format( + amount=f'{amount_rub:.0f}', + ) + + keyboard_buttons: list[list[InlineKeyboardButton]] = [] + if payment_url: + keyboard_buttons.append([InlineKeyboardButton(text=pay_button_text, url=payment_url)]) + keyboard_buttons.append( + [ + InlineKeyboardButton( + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', + ) + ] + ) + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + + if payment_url: + response_text = texts.t( + 'DONUT_PAYMENT_CREATED', + '\U0001f4b3 Оплата через {name}\n\n' + 'Сумма: {amount}₽\n\n' + 'Нажмите кнопку ниже для перехода к оплате.\n' + 'После подтверждения платежа баланс будет пополнен автоматически.', + ).format(name=name, amount=f'{amount_rub:.2f}') + else: + response_text = texts.t( + 'DONUT_PAYMENT_PROCESSING', + '\U0001f4b3 Платёж создан через {name}\n\n' + 'Сумма: {amount}₽\n\n' + 'Платёж в обработке. Реквизиты будут отправлены отдельным сообщением.', + ).format(name=name, amount=f'{amount_rub:.2f}') + + if edit_message: + await message_or_callback.edit_text(response_text, reply_markup=keyboard, parse_mode='HTML') + else: + await message_or_callback.answer(response_text, reply_markup=keyboard, parse_mode='HTML') + + logger.info('Donut payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub) + + +@error_handler +async def process_donut_payment_amount( + message: types.Message, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + state: FSMContext, +): + """Обрабатывает сумму, введённую пользователем для Donut.""" + texts = get_texts(db_user.language) + + restriction_kb = _check_topup_restriction(db_user, texts) + if restriction_kb: + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + await message.answer( + f'\U0001f6ab Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=restriction_kb, + ) + await state.clear() + return + + min_amount = settings.DONUT_MIN_AMOUNT_KOPEKS + max_amount = settings.DONUT_MAX_AMOUNT_KOPEKS + + if amount_kopeks < min_amount: + await message.answer( + texts.t( + 'PAYMENT_AMOUNT_TOO_LOW', + 'Минимальная сумма пополнения: {min_amount}₽', + ).format(min_amount=min_amount // 100), + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + return + + if amount_kopeks > max_amount: + await message.answer( + texts.t( + 'PAYMENT_AMOUNT_TOO_HIGH', + 'Максимальная сумма пополнения: {max_amount}₽', + ).format(max_amount=max_amount // 100), + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + return + + data = await state.get_data() + payment_method = data.get('payment_method', 'donut') + payment_method_type = _extract_service_type(payment_method) + display_name = _get_display_name(payment_method) + + await state.clear() + + await _create_donut_payment_and_respond( + message_or_callback=message, + db_user=db_user, + db=db, + amount_kopeks=amount_kopeks, + edit_message=False, + payment_method_type=payment_method_type, + display_name=display_name, + ) + + +async def _start_donut_topup_impl( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + payment_method: str, +): + """Стартует FSM ввода суммы для Donut.""" + texts = get_texts(db_user.language) + + restriction_kb = _check_topup_restriction(db_user, texts) + if restriction_kb: + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + await callback.message.edit_text( + f'\U0001f6ab Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=restriction_kb, + ) + return + + await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data(payment_method=payment_method) + + min_amount = settings.DONUT_MIN_AMOUNT_KOPEKS // 100 + max_amount = settings.DONUT_MAX_AMOUNT_KOPEKS // 100 + + display_name = _get_display_name(payment_method) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', + ) + ] + ] + ) + + await callback.message.edit_text( + texts.t( + 'DONUT_ENTER_AMOUNT', + '\U0001f4b3 Пополнение через {name}\n\n' + 'Введите сумму пополнения в рублях.\n\n' + 'Минимум: {min_amount}₽\n' + 'Максимум: {max_amount}₽', + ).format( + name=display_name, + min_amount=min_amount, + max_amount=f'{max_amount:,}'.replace(',', ' '), + ), + parse_mode='HTML', + reply_markup=keyboard, + ) + + +@error_handler +async def start_donut_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_donut_topup_impl(callback, db_user, state, 'donut') + + +@error_handler +async def start_donut_card_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_donut_topup_impl(callback, db_user, state, 'donut_card') + + +@error_handler +async def start_donut_sbp_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_donut_topup_impl(callback, db_user, state, 'donut_sbp') + + +@error_handler +async def start_donut_sbp_qr_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_donut_topup_impl(callback, db_user, state, 'donut_sbp_qr') diff --git a/app/handlers/balance/etoplatezhi.py b/app/handlers/balance/etoplatezhi.py new file mode 100644 index 00000000..88e035c0 --- /dev/null +++ b/app/handlers/balance/etoplatezhi.py @@ -0,0 +1,302 @@ +"""Handler for Etoplatezhi balance top-up.""" + +import html + +import structlog +from aiogram import types +from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.keyboards.inline import get_back_keyboard +from app.localization.texts import get_texts +from app.services.payment_service import PaymentService +from app.states import BalanceStates +from app.utils.decorators import error_handler + + +logger = structlog.get_logger(__name__) + + +def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None: + """Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None.""" + if not getattr(db_user, 'restriction_topup', False): + return None + + keyboard = [] + support_url = settings.get_support_contact_url() + if support_url: + keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +async def _create_etoplatezhi_payment_and_respond( + message_or_callback, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + edit_message: bool = False, + payment_method_type: str | None = None, +): + """ + Common logic for creating Etoplatezhi payment and sending response. + """ + texts = get_texts(db_user.language) + amount_rub = amount_kopeks / 100 + + # Create payment + payment_service = PaymentService() + + description = settings.PAYMENT_BALANCE_TEMPLATE.format( + service_name=settings.PAYMENT_SERVICE_NAME, + description='Пополнение баланса', + ) + + result = await payment_service.create_etoplatezhi_payment( + db=db, + user_id=db_user.id, + amount_kopeks=amount_kopeks, + description=description, + email=getattr(db_user, 'email', None), + language=db_user.language, + payment_method_type=payment_method_type, + ) + + if not result: + error_text = texts.t( + 'PAYMENT_CREATE_ERROR', + 'Не удалось создать платёж. Попробуйте позже.', + ) + if edit_message: + await message_or_callback.edit_text( + error_text, + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + else: + await message_or_callback.answer( + error_text, + parse_mode='HTML', + ) + return + + payment_url = result.get('payment_url') + display_name = settings.get_etoplatezhi_display_name() + + # Create keyboard with payment button + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t( + 'PAY_BUTTON', + '\U0001f4b3 Оплатить {amount}\u20bd', + ).format(amount=f'{amount_rub:.0f}'), + url=payment_url, + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'), + callback_data='menu_balance', + ) + ], + ] + ) + + response_text = texts.t( + 'ETOPLATEZHI_PAYMENT_CREATED', + '\U0001f4b3 Оплата через {name}\n\n' + 'Сумма: {amount}\u20bd\n\n' + 'Нажмите кнопку ниже для оплаты.\n' + 'После успешной оплаты баланс будет пополнен автоматически.', + ).format(name=display_name, amount=f'{amount_rub:.2f}') + + if edit_message: + await message_or_callback.edit_text( + response_text, + reply_markup=keyboard, + parse_mode='HTML', + ) + else: + await message_or_callback.answer( + response_text, + reply_markup=keyboard, + parse_mode='HTML', + ) + + logger.info('Etoplatezhi payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub) + + +@error_handler +async def process_etoplatezhi_payment_amount( + message: types.Message, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + state: FSMContext, +): + """ + Process payment amount directly. + """ + texts = get_texts(db_user.language) + + restriction_kb = _check_topup_restriction(db_user, texts) + if restriction_kb: + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + await message.answer( + f'\U0001f6ab Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=restriction_kb, + ) + await state.clear() + return + + # Validate amount + min_amount = settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS + max_amount = settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS + + if amount_kopeks < min_amount: + await message.answer( + texts.t( + 'PAYMENT_AMOUNT_TOO_LOW', + 'Минимальная сумма пополнения: {min_amount}\u20bd', + ).format(min_amount=min_amount // 100), + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + return + + if amount_kopeks > max_amount: + await message.answer( + texts.t( + 'PAYMENT_AMOUNT_TOO_HIGH', + 'Максимальная сумма пополнения: {max_amount}\u20bd', + ).format(max_amount=max_amount // 100), + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + return + + data = await state.get_data() + payment_method = data.get('payment_method', 'etoplatezhi') + # etoplatezhi_sbp → 'sbp', etoplatezhi_card → 'card', etoplatezhi → None + payment_method_type = _extract_service_type(payment_method) + + await state.clear() + + await _create_etoplatezhi_payment_and_respond( + message_or_callback=message, + db_user=db_user, + db=db, + amount_kopeks=amount_kopeks, + edit_message=False, + payment_method_type=payment_method_type, + ) + + +ETOPLATEZHI_PAYMENT_METHODS = {'etoplatezhi', 'etoplatezhi_sbp', 'etoplatezhi_card'} + +ETOPLATEZHI_SERVICE_MAP: dict[str, str | None] = { + 'etoplatezhi': None, + 'etoplatezhi_sbp': 'sbp', + 'etoplatezhi_card': 'card', +} + + +def _extract_service_type(payment_method: str) -> str | None: + return ETOPLATEZHI_SERVICE_MAP.get(payment_method) + + +async def _start_etoplatezhi_topup_impl( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + payment_method: str, +): + """Common logic for starting Etoplatezhi top-up (generic / SBP / card).""" + texts = get_texts(db_user.language) + + restriction_kb = _check_topup_restriction(db_user, texts) + if restriction_kb: + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + await callback.message.edit_text( + f'\U0001f6ab Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=restriction_kb, + ) + return + + await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data(payment_method=payment_method) + + min_amount = settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS // 100 + max_amount = settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS // 100 + + # Choose display name based on sub-method + if payment_method == 'etoplatezhi_sbp': + display_name = settings.get_etoplatezhi_sbp_display_name() + elif payment_method == 'etoplatezhi_card': + display_name = settings.get_etoplatezhi_card_display_name() + else: + display_name = settings.get_etoplatezhi_display_name() + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'), + callback_data='menu_balance', + ) + ] + ] + ) + + await callback.message.edit_text( + texts.t( + 'ETOPLATEZHI_ENTER_AMOUNT', + '\U0001f4b3 Пополнение через {name}\n\n' + 'Введите сумму пополнения в рублях.\n\n' + 'Минимум: {min_amount}\u20bd\n' + 'Максимум: {max_amount}\u20bd', + ).format( + name=display_name, + min_amount=min_amount, + max_amount=f'{max_amount:,}'.replace(',', ' '), + ), + parse_mode='HTML', + reply_markup=keyboard, + ) + + +@error_handler +async def start_etoplatezhi_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi') + + +@error_handler +async def start_etoplatezhi_sbp_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi_sbp') + + +@error_handler +async def start_etoplatezhi_card_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi_card') diff --git a/app/handlers/balance/jupiter.py b/app/handlers/balance/jupiter.py new file mode 100644 index 00000000..f1120eca --- /dev/null +++ b/app/handlers/balance/jupiter.py @@ -0,0 +1,273 @@ +"""Handler for Jupiter balance top-up (FPGate P2P v2.1).""" + +import html + +import structlog +from aiogram import types +from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.keyboards.inline import get_back_keyboard +from app.localization.texts import get_texts +from app.services.payment_service import PaymentService +from app.states import BalanceStates +from app.utils.decorators import error_handler + + +logger = structlog.get_logger(__name__) + + +JUPITER_PAYMENT_METHODS = {'jupiter', 'jupiter_sbp'} + +JUPITER_SERVICE_MAP: dict[str, str | None] = { + 'jupiter': None, + 'jupiter_sbp': 'sbp', +} + + +def _extract_service_type(payment_method: str) -> str | None: + return JUPITER_SERVICE_MAP.get(payment_method) + + +def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None: + """Проверяет ограничение на пополнение.""" + if not getattr(db_user, 'restriction_topup', False): + return None + + keyboard = [] + support_url = settings.get_support_contact_url() + if support_url: + keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +async def _create_jupiter_payment_and_respond( + message_or_callback, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + edit_message: bool = False, + payment_method_type: str | None = None, +): + """Создаёт платёж Jupiter и отправляет ссылку/QR пользователю.""" + texts = get_texts(db_user.language) + amount_rub = amount_kopeks / 100 + + payment_service = PaymentService() + description = settings.PAYMENT_BALANCE_TEMPLATE.format( + service_name=settings.PAYMENT_SERVICE_NAME, + description='Пополнение баланса', + ) + + result = await payment_service.create_jupiter_payment( + db=db, + user_id=db_user.id, + amount_kopeks=amount_kopeks, + description=description, + email=getattr(db_user, 'email', None), + language=db_user.language, + payment_method_type=payment_method_type, + ) + + if not result: + error_text = texts.t( + 'PAYMENT_CREATE_ERROR', + 'Не удалось создать платёж. Попробуйте позже.', + ) + if edit_message: + await message_or_callback.edit_text( + error_text, + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + else: + await message_or_callback.answer(error_text, parse_mode='HTML') + return + + payment_url = result.get('payment_url') + display_name = settings.get_jupiter_display_name() + + pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}₽').format( + amount=f'{amount_rub:.0f}', + ) + + keyboard_buttons: list[list[InlineKeyboardButton]] = [] + if payment_url: + keyboard_buttons.append([InlineKeyboardButton(text=pay_button_text, url=payment_url)]) + keyboard_buttons.append( + [ + InlineKeyboardButton( + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', + ) + ] + ) + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + + if payment_url: + response_text = texts.t( + 'JUPITER_PAYMENT_CREATED', + '\U0001f4b3 Оплата через {name}\n\n' + 'Сумма: {amount}₽\n\n' + 'Нажмите кнопку ниже, чтобы открыть QR-код СБП и оплатить.\n' + 'Баланс будет пополнен автоматически после подтверждения платежа.', + ).format(name=display_name, amount=f'{amount_rub:.2f}') + else: + response_text = texts.t( + 'JUPITER_PAYMENT_PROCESSING', + '\U0001f4b3 Платёж создан через {name}\n\n' + 'Сумма: {amount}₽\n\n' + 'Платёж в обработке. Реквизиты будут отправлены отдельным сообщением.', + ).format(name=display_name, amount=f'{amount_rub:.2f}') + + if edit_message: + await message_or_callback.edit_text(response_text, reply_markup=keyboard, parse_mode='HTML') + else: + await message_or_callback.answer(response_text, reply_markup=keyboard, parse_mode='HTML') + + logger.info('Jupiter payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub) + + +@error_handler +async def process_jupiter_payment_amount( + message: types.Message, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + state: FSMContext, +): + """Обрабатывает сумму, введённую пользователем для Jupiter.""" + texts = get_texts(db_user.language) + + restriction_kb = _check_topup_restriction(db_user, texts) + if restriction_kb: + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + await message.answer( + f'\U0001f6ab Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=restriction_kb, + ) + await state.clear() + return + + min_amount = settings.JUPITER_MIN_AMOUNT_KOPEKS + max_amount = settings.JUPITER_MAX_AMOUNT_KOPEKS + + if amount_kopeks < min_amount: + await message.answer( + texts.t( + 'PAYMENT_AMOUNT_TOO_LOW', + 'Минимальная сумма пополнения: {min_amount}₽', + ).format(min_amount=min_amount // 100), + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + return + + if amount_kopeks > max_amount: + await message.answer( + texts.t( + 'PAYMENT_AMOUNT_TOO_HIGH', + 'Максимальная сумма пополнения: {max_amount}₽', + ).format(max_amount=max_amount // 100), + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + return + + data = await state.get_data() + payment_method = data.get('payment_method', 'jupiter') + payment_method_type = _extract_service_type(payment_method) + + await state.clear() + + await _create_jupiter_payment_and_respond( + message_or_callback=message, + db_user=db_user, + db=db, + amount_kopeks=amount_kopeks, + edit_message=False, + payment_method_type=payment_method_type, + ) + + +async def _start_jupiter_topup_impl( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + payment_method: str, +): + """Стартует FSM ввода суммы для Jupiter.""" + texts = get_texts(db_user.language) + + restriction_kb = _check_topup_restriction(db_user, texts) + if restriction_kb: + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + await callback.message.edit_text( + f'\U0001f6ab Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=restriction_kb, + ) + return + + await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data(payment_method=payment_method) + + min_amount = settings.JUPITER_MIN_AMOUNT_KOPEKS // 100 + max_amount = settings.JUPITER_MAX_AMOUNT_KOPEKS // 100 + + if payment_method == 'jupiter_sbp': + display_name = settings.get_jupiter_sbp_display_name() + else: + display_name = settings.get_jupiter_display_name() + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', + ) + ] + ] + ) + + await callback.message.edit_text( + texts.t( + 'JUPITER_ENTER_AMOUNT', + '\U0001f4b3 Пополнение через {name}\n\n' + 'Введите сумму пополнения в рублях.\n\n' + 'Минимум: {min_amount}₽\n' + 'Максимум: {max_amount}₽', + ).format( + name=display_name, + min_amount=min_amount, + max_amount=f'{max_amount:,}'.replace(',', ' '), + ), + parse_mode='HTML', + reply_markup=keyboard, + ) + + +@error_handler +async def start_jupiter_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_jupiter_topup_impl(callback, db_user, state, 'jupiter') + + +@error_handler +async def start_jupiter_sbp_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_jupiter_topup_impl(callback, db_user, state, 'jupiter_sbp') diff --git a/app/handlers/balance/lava.py b/app/handlers/balance/lava.py new file mode 100644 index 00000000..3b165c96 --- /dev/null +++ b/app/handlers/balance/lava.py @@ -0,0 +1,284 @@ +"""Handler for Lava balance top-up (Lava Business, gate.lava.ru).""" + +import html + +import structlog +from aiogram import types +from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.keyboards.inline import get_back_keyboard +from app.localization.texts import get_texts +from app.services.payment_service import PaymentService +from app.states import BalanceStates +from app.utils.decorators import error_handler + + +logger = structlog.get_logger(__name__) + + +LAVA_PAYMENT_METHODS = {'lava', 'lava_card', 'lava_sbp'} + +LAVA_SERVICE_MAP: dict[str, str | None] = { + 'lava': None, + 'lava_card': 'card', + 'lava_sbp': 'sbp', +} + + +def _extract_service_type(payment_method: str) -> str | None: + return LAVA_SERVICE_MAP.get(payment_method) + + +def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None: + """Проверяет ограничение на пополнение.""" + if not getattr(db_user, 'restriction_topup', False): + return None + + keyboard = [] + support_url = settings.get_support_contact_url() + if support_url: + keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def _get_display_name(payment_method: str) -> str: + if payment_method == 'lava_card': + return settings.get_lava_card_display_name() + if payment_method == 'lava_sbp': + return settings.get_lava_sbp_display_name() + return settings.get_lava_display_name() + + +async def _create_lava_payment_and_respond( + message_or_callback, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + edit_message: bool = False, + payment_method_type: str | None = None, + display_name: str | None = None, +): + """Создаёт инвойс Lava и отправляет ссылку пользователю.""" + texts = get_texts(db_user.language) + amount_rub = amount_kopeks / 100 + + payment_service = PaymentService() + description = settings.PAYMENT_BALANCE_TEMPLATE.format( + service_name=settings.PAYMENT_SERVICE_NAME, + description='Пополнение баланса', + ) + + result = await payment_service.create_lava_payment( + db=db, + user_id=db_user.id, + amount_kopeks=amount_kopeks, + description=description, + email=getattr(db_user, 'email', None), + language=db_user.language, + payment_method_type=payment_method_type, + ) + + if not result: + error_text = texts.t( + 'PAYMENT_CREATE_ERROR', + 'Не удалось создать платёж. Попробуйте позже.', + ) + if edit_message: + await message_or_callback.edit_text( + error_text, + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + else: + await message_or_callback.answer(error_text, parse_mode='HTML') + return + + payment_url = result.get('payment_url') + name = display_name or settings.get_lava_display_name() + + # Без URL mixin вернул бы None ещё до этого блока; здесь URL гарантирован. + pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}₽').format( + amount=f'{amount_rub:.0f}', + ) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=pay_button_text, url=payment_url)], + [ + InlineKeyboardButton( + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', + ) + ], + ] + ) + + response_text = texts.t( + 'LAVA_PAYMENT_CREATED', + '\U0001f4b3 Оплата через {name}\n\n' + 'Сумма: {amount}₽\n\n' + 'Нажмите кнопку ниже для перехода к оплате.\n' + 'После подтверждения платежа баланс будет пополнен автоматически.', + ).format(name=name, amount=f'{amount_rub:.2f}') + + if edit_message: + await message_or_callback.edit_text(response_text, reply_markup=keyboard, parse_mode='HTML') + else: + await message_or_callback.answer(response_text, reply_markup=keyboard, parse_mode='HTML') + + logger.info('Lava payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub) + + +@error_handler +async def process_lava_payment_amount( + message: types.Message, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + state: FSMContext, +): + """Обрабатывает сумму для Lava.""" + texts = get_texts(db_user.language) + + restriction_kb = _check_topup_restriction(db_user, texts) + if restriction_kb: + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + await message.answer( + f'\U0001f6ab Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=restriction_kb, + ) + await state.clear() + return + + min_amount = settings.LAVA_MIN_AMOUNT_KOPEKS + max_amount = settings.LAVA_MAX_AMOUNT_KOPEKS + + if amount_kopeks < min_amount: + await message.answer( + texts.t( + 'PAYMENT_AMOUNT_TOO_LOW', + 'Минимальная сумма пополнения: {min_amount}₽', + ).format(min_amount=min_amount // 100), + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + return + + if amount_kopeks > max_amount: + await message.answer( + texts.t( + 'PAYMENT_AMOUNT_TOO_HIGH', + 'Максимальная сумма пополнения: {max_amount}₽', + ).format(max_amount=max_amount // 100), + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + return + + data = await state.get_data() + payment_method = data.get('payment_method', 'lava') + payment_method_type = _extract_service_type(payment_method) + display_name = _get_display_name(payment_method) + + await state.clear() + + await _create_lava_payment_and_respond( + message_or_callback=message, + db_user=db_user, + db=db, + amount_kopeks=amount_kopeks, + edit_message=False, + payment_method_type=payment_method_type, + display_name=display_name, + ) + + +async def _start_lava_topup_impl( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + payment_method: str, +): + """Стартует FSM ввода суммы для Lava.""" + texts = get_texts(db_user.language) + + restriction_kb = _check_topup_restriction(db_user, texts) + if restriction_kb: + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + await callback.message.edit_text( + f'\U0001f6ab Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=restriction_kb, + ) + return + + await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data(payment_method=payment_method) + + min_amount = settings.LAVA_MIN_AMOUNT_KOPEKS // 100 + max_amount = settings.LAVA_MAX_AMOUNT_KOPEKS // 100 + + display_name = _get_display_name(payment_method) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', + ) + ] + ] + ) + + await callback.message.edit_text( + texts.t( + 'LAVA_ENTER_AMOUNT', + '\U0001f4b3 Пополнение через {name}\n\n' + 'Введите сумму пополнения в рублях.\n\n' + 'Минимум: {min_amount}₽\n' + 'Максимум: {max_amount}₽', + ).format( + name=display_name, + min_amount=min_amount, + max_amount=f'{max_amount:,}'.replace(',', ' '), + ), + parse_mode='HTML', + reply_markup=keyboard, + ) + + +@error_handler +async def start_lava_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_lava_topup_impl(callback, db_user, state, 'lava') + + +@error_handler +async def start_lava_card_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_lava_topup_impl(callback, db_user, state, 'lava_card') + + +@error_handler +async def start_lava_sbp_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_lava_topup_impl(callback, db_user, state, 'lava_sbp') diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 98a365c6..ed0ae81c 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -170,13 +170,48 @@ async def route_payment_by_method( await process_overpay_payment_amount(message, db_user, db, amount_kopeks, state) return True - if payment_method == 'aurapay': + if payment_method in ('aurapay', 'aurapay_sbp', 'aurapay_card'): from .aurapay import process_aurapay_payment_amount async with AsyncSessionLocal() as db: await process_aurapay_payment_amount(message, db_user, db, amount_kopeks, state) return True + if payment_method in ('etoplatezhi', 'etoplatezhi_sbp', 'etoplatezhi_card'): + from .etoplatezhi import process_etoplatezhi_payment_amount + + async with AsyncSessionLocal() as db: + await process_etoplatezhi_payment_amount(message, db_user, db, amount_kopeks, state) + return True + + if payment_method in ('antilopay', 'antilopay_sbp', 'antilopay_card', 'antilopay_sberpay'): + from .antilopay import process_antilopay_payment_amount + + async with AsyncSessionLocal() as db: + await process_antilopay_payment_amount(message, db_user, db, amount_kopeks, state) + return True + + if payment_method in ('jupiter', 'jupiter_sbp'): + from .jupiter import process_jupiter_payment_amount + + async with AsyncSessionLocal() as db: + await process_jupiter_payment_amount(message, db_user, db, amount_kopeks, state) + return True + + if payment_method in ('donut', 'donut_card', 'donut_sbp', 'donut_sbp_qr'): + from .donut import process_donut_payment_amount + + async with AsyncSessionLocal() as db: + await process_donut_payment_amount(message, db_user, db, amount_kopeks, state) + return True + + if payment_method in ('lava', 'lava_card', 'lava_sbp'): + from .lava import process_lava_payment_amount + + async with AsyncSessionLocal() as db: + await process_lava_payment_amount(message, db_user, db, amount_kopeks, state) + return True + if payment_method == 'riopay': from .riopay import process_riopay_payment_amount @@ -768,9 +803,52 @@ def register_balance_handlers(dp: Dispatcher): dp.callback_query.register(start_overpay_topup, F.data == 'topup_overpay') - from .aurapay import start_aurapay_topup + from .aurapay import start_aurapay_card_topup, start_aurapay_sbp_topup, start_aurapay_topup dp.callback_query.register(start_aurapay_topup, F.data == 'topup_aurapay') + dp.callback_query.register(start_aurapay_sbp_topup, F.data == 'topup_aurapay_sbp') + dp.callback_query.register(start_aurapay_card_topup, F.data == 'topup_aurapay_card') + + from .etoplatezhi import start_etoplatezhi_card_topup, start_etoplatezhi_sbp_topup, start_etoplatezhi_topup + + dp.callback_query.register(start_etoplatezhi_topup, F.data == 'topup_etoplatezhi') + dp.callback_query.register(start_etoplatezhi_sbp_topup, F.data == 'topup_etoplatezhi_sbp') + dp.callback_query.register(start_etoplatezhi_card_topup, F.data == 'topup_etoplatezhi_card') + + from .antilopay import ( + start_antilopay_card_topup, + start_antilopay_sberpay_topup, + start_antilopay_sbp_topup, + start_antilopay_topup, + ) + + dp.callback_query.register(start_antilopay_topup, F.data == 'topup_antilopay') + dp.callback_query.register(start_antilopay_sbp_topup, F.data == 'topup_antilopay_sbp') + dp.callback_query.register(start_antilopay_card_topup, F.data == 'topup_antilopay_card') + dp.callback_query.register(start_antilopay_sberpay_topup, F.data == 'topup_antilopay_sberpay') + + from .jupiter import start_jupiter_sbp_topup, start_jupiter_topup + + dp.callback_query.register(start_jupiter_topup, F.data == 'topup_jupiter') + dp.callback_query.register(start_jupiter_sbp_topup, F.data == 'topup_jupiter_sbp') + + from .donut import ( + start_donut_card_topup, + start_donut_sbp_qr_topup, + start_donut_sbp_topup, + start_donut_topup, + ) + + dp.callback_query.register(start_donut_topup, F.data == 'topup_donut') + dp.callback_query.register(start_donut_card_topup, F.data == 'topup_donut_card') + dp.callback_query.register(start_donut_sbp_topup, F.data == 'topup_donut_sbp') + dp.callback_query.register(start_donut_sbp_qr_topup, F.data == 'topup_donut_sbp_qr') + + from .lava import start_lava_card_topup, start_lava_sbp_topup, start_lava_topup + + dp.callback_query.register(start_lava_topup, F.data == 'topup_lava') + dp.callback_query.register(start_lava_card_topup, F.data == 'topup_lava_card') + dp.callback_query.register(start_lava_sbp_topup, F.data == 'topup_lava_sbp') from .mulenpay import check_mulenpay_payment_status diff --git a/app/handlers/start.py b/app/handlers/start.py index da227091..1ab4b3ed 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -49,7 +49,11 @@ from app.services.pinned_message_service import ( get_active_pinned_message, ) from app.services.privacy_policy_service import PrivacyPolicyService -from app.services.referral_service import process_referral_registration, save_pending_referral +from app.services.referral_service import ( + process_referral_registration, + save_pending_campaign, + save_pending_referral, +) from app.services.subscription_service import SubscriptionService from app.services.support_settings_service import SupportSettingsService from app.services.web_auth_service import WEB_AUTH_TOKEN_MIN_LENGTH, link_web_auth_token @@ -386,6 +390,17 @@ async def _apply_campaign_bonus_if_needed( if not result.success: return None + # Bot-flow successfully applied the campaign — clear the Redis pending entry + # (set in cmd_start as a fallback for the cabinet WebApp path) so it isn't + # re-evaluated on a subsequent cabinet login. + try: + from app.services.referral_service import clear_pending_campaign + + if getattr(user, 'telegram_id', None): + await clear_pending_campaign(user.telegram_id) + except Exception: + pass + if result.bonus_type == 'balance': amount_text = texts.format_price(result.balance_kopeks) return texts.CAMPAIGN_BONUS_BALANCE.format( @@ -724,6 +739,23 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, start_parameter=campaign.start_parameter, ) await state.update_data(campaign_id=campaign.id) + # Persist campaign to Redis immediately so it survives if user opens + # miniapp/cabinet (via Telegram menu button) before completing the + # bot registration flow. Mirrors the pending_referral mechanism. + # Only for new users — existing users already had attribution applied. + if not db_user: + try: + await save_pending_campaign( + message.from_user.id, + campaign.start_parameter, + campaign.id, + ) + except Exception as exc: + logger.warning( + 'Failed to persist pending campaign', + campaign_id=campaign.id, + error=exc, + ) if campaign.partner_user_id: await state.update_data(referrer_id=campaign.partner_user_id) logger.info( @@ -2416,6 +2448,20 @@ async def required_sub_channel_check( campaign_id=campaign.id, partner_user_id=campaign.partner_user_id, ) + # Mirror save in Redis so cabinet WebApp auth can pick it up + # if user opens miniapp before completing registration. + try: + await save_pending_campaign( + query.from_user.id, + campaign.start_parameter, + campaign.id, + ) + except Exception as exc: + logger.warning( + 'Failed to persist pending campaign after channel check', + campaign_id=campaign.id, + error=exc, + ) else: state_data['referral_code'] = pending_start_payload logger.info( diff --git a/app/handlers/subscription/common.py b/app/handlers/subscription/common.py index c33f86b2..9c2d2146 100644 --- a/app/handlers/subscription/common.py +++ b/app/handlers/subscription/common.py @@ -406,11 +406,37 @@ async def get_apps_for_platform_async(device_type: str, language: str = 'ru') -> def normalize_app(app: dict[str, Any]) -> dict[str, Any]: """Normalize Remnawave app dict to a unified format with blocks.""" + + # Extract urlScheme from blocks if not present at root level + url_scheme = app.get('urlScheme', '') + + if not url_scheme: + # Try to extract from subscriptionLink button in blocks + blocks = app.get('blocks', []) + for block in blocks: + if not isinstance(block, dict): + continue + buttons = block.get('buttons', []) + for btn in buttons: + if not isinstance(btn, dict): + continue + if btn.get('type') == 'subscriptionLink': + link = btn.get('link', '') or btn.get('url', '') + if '{{SUBSCRIPTION_LINK}}' in link: + url_scheme = link.split('{{SUBSCRIPTION_LINK}}')[0] + break + if url_scheme: + break + + # Validate extracted scheme contains :// + if url_scheme and '://' not in url_scheme: + url_scheme = '' + return { 'id': app.get('id', app.get('name', 'unknown')), 'name': app.get('name', ''), 'isFeatured': app.get('featured', app.get('isFeatured', False)), - 'urlScheme': app.get('urlScheme', ''), + 'urlScheme': url_scheme, 'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False), 'blocks': app.get('blocks', []), '_raw': app, diff --git a/app/handlers/subscription/my_subscriptions.py b/app/handlers/subscription/my_subscriptions.py index 1500b0c4..e8b0ac4b 100644 --- a/app/handlers/subscription/my_subscriptions.py +++ b/app/handlers/subscription/my_subscriptions.py @@ -132,6 +132,16 @@ def _build_subscription_detail_keyboard(sub_id: int, sub=None) -> types.InlineKe if is_inactive: buttons.append([types.InlineKeyboardButton(text='🗑 Удалить подписку', callback_data=f'sub_del:{sub_id}')]) + if not is_inactive and settings.is_subscription_revoke_enabled(): + buttons.append( + [ + types.InlineKeyboardButton( + text='🔄 Перевыпустить', + callback_data=f'sr:{sub_id}', + ) + ] + ) + buttons.append([types.InlineKeyboardButton(text='◀️ К списку подписок', callback_data='my_subscriptions')]) return types.InlineKeyboardMarkup(inline_keyboard=buttons) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index ff98ad64..46c1aebd 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -4141,6 +4141,17 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(handle_change_devices_menu, F.data.startswith('change_devices_menu:')) dp.callback_query.register(handle_device_management_menu, F.data.startswith('device_management:')) + # Subscription revoke (reissue) + from app.handlers.subscription.revoke import ( + confirm_subscription_revoke, + start_multi_revoke, + start_subscription_revoke, + ) + + dp.callback_query.register(start_subscription_revoke, F.data == 'subscription_revoke') + dp.callback_query.register(confirm_subscription_revoke, F.data == 'subscription_revoke_confirm') + dp.callback_query.register(start_multi_revoke, F.data.startswith('sr:')) + dp.callback_query.register(show_trial_offer, F.data == 'menu_trial') dp.callback_query.register(activate_trial, F.data == 'trial_activate') diff --git a/app/handlers/subscription/revoke.py b/app/handlers/subscription/revoke.py new file mode 100644 index 00000000..00f75afc --- /dev/null +++ b/app/handlers/subscription/revoke.py @@ -0,0 +1,350 @@ +"""Handler for subscription reissue (revoke + regenerate link).""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import structlog +from aiogram import types +from aiogram.fsm.context import FSMContext +from aiogram.types import InaccessibleMessage, InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.subscription import get_subscription_by_id_for_user +from app.database.models import Subscription, User +from app.localization.texts import get_texts +from app.services.subscription_service import SubscriptionService +from app.utils.decorators import error_handler + + +logger = structlog.get_logger(__name__) + + +def _check_revoke_cooldown(subscription: Subscription) -> int | None: + """Returns remaining seconds if on cooldown, None if ready.""" + if not subscription.last_revoke_at: + return None + elapsed = (datetime.now(UTC) - subscription.last_revoke_at).total_seconds() + cooldown = settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS + if elapsed < cooldown: + return int(cooldown - elapsed) + return None + + +def _build_revoke_confirm_keyboard( + language: str, + multi_tariff: bool = False, +) -> InlineKeyboardMarkup: + """Build confirmation keyboard for revoke action.""" + texts = get_texts(language) + back_callback = 'my_subscriptions' if multi_tariff else 'subscription_settings' + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('SUBSCRIPTION_REVOKE_CONFIRM_BTN', '✅ Подтвердить'), + callback_data='subscription_revoke_confirm', + ), + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data=back_callback, + ), + ], + ] + ) + + +def _build_revoke_success_keyboard( + language: str, + multi_tariff: bool = False, +) -> InlineKeyboardMarkup: + """Build success keyboard with connect and back buttons.""" + texts = get_texts(language) + back_callback = 'my_subscriptions' if multi_tariff else 'menu_subscription' + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('SUBSCRIPTION_REVOKE_CONNECT_BTN', '🔗 Подключиться'), + callback_data='subscription_connect', + ), + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data=back_callback, + ), + ], + ] + ) + + +# --------------------------------------------------------------------------- +# Classic mode (single subscription) +# --------------------------------------------------------------------------- + + +@error_handler +async def start_subscription_revoke( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext | None = None, +) -> None: + """Show revoke confirmation for classic single-subscription mode.""" + if isinstance(callback.message, InaccessibleMessage): + await callback.answer() + return + + texts = get_texts(db_user.language) + + if not settings.is_subscription_revoke_enabled(): + await callback.answer( + texts.t('SUBSCRIPTION_REVOKE_DISABLED', 'Перевыпуск подписки недоступен'), + show_alert=True, + ) + return + + subscription = db_user.subscription + if not subscription or not subscription.is_active: + await callback.answer( + texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'), + show_alert=True, + ) + return + + # Check cooldown + remaining = _check_revoke_cooldown(subscription) + if remaining is not None: + minutes = remaining // 60 + seconds = remaining % 60 + await callback.answer( + texts.t( + 'SUBSCRIPTION_REVOKE_COOLDOWN', + '⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.', + ).format(minutes=minutes, seconds=seconds), + show_alert=True, + ) + return + + await callback.answer() + + await callback.message.edit_text( + texts.t( + 'SUBSCRIPTION_REVOKE_WARNING', + ( + '⚠️ Перевыпуск подписки\n\n' + 'Это действие:\n' + '• Сгенерирует новую ссылку подключения\n' + '• Сбросит все подключённые устройства\n' + '• Старая ссылка перестанет работать\n\n' + 'Продолжить?' + ), + ), + reply_markup=_build_revoke_confirm_keyboard(db_user.language, multi_tariff=False), + parse_mode='HTML', + ) + + +@error_handler +async def confirm_subscription_revoke( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext | None = None, +) -> None: + """Execute revoke for classic or multi-tariff mode (uses FSM state for multi).""" + if isinstance(callback.message, InaccessibleMessage): + await callback.answer() + return + + texts = get_texts(db_user.language) + + if not settings.is_subscription_revoke_enabled(): + await callback.answer( + texts.t('SUBSCRIPTION_REVOKE_DISABLED', 'Перевыпуск подписки недоступен'), + show_alert=True, + ) + return + + # Determine subscription: multi-tariff via FSM state or classic via db_user + is_multi = False + subscription: Subscription | None = None + + if state: + data = await state.get_data() + revoke_sub_id = data.get('revoke_sub_id') + if revoke_sub_id is not None: + is_multi = True + subscription = await get_subscription_by_id_for_user(db, revoke_sub_id, db_user.id) + if not subscription: + await callback.answer( + texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'), + show_alert=True, + ) + return + + if subscription is None: + subscription = db_user.subscription + + if not subscription or not subscription.is_active: + await callback.answer( + texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'), + show_alert=True, + ) + return + + # TOCTOU protection: re-check cooldown + remaining = _check_revoke_cooldown(subscription) + if remaining is not None: + minutes = remaining // 60 + seconds = remaining % 60 + await callback.answer( + texts.t( + 'SUBSCRIPTION_REVOKE_COOLDOWN', + '⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.', + ).format(minutes=minutes, seconds=seconds), + show_alert=True, + ) + return + + # Answer callback BEFORE heavy operation + await callback.answer() + + # Execute revoke + sub_service = SubscriptionService() + new_url = await sub_service.revoke_subscription(db, subscription) + + if not new_url: + await callback.message.edit_text( + texts.t('SUBSCRIPTION_REVOKE_ERROR', '❌ Ошибка при перевыпуске подписки. Попробуйте позже.'), + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')], + ] + ), + parse_mode='HTML', + ) + return + + # Update cooldown timestamp + subscription.last_revoke_at = datetime.now(UTC) + await db.commit() + + logger.info( + 'Subscription revoked successfully', + user_id=db_user.id, + subscription_id=subscription.id, + is_multi=is_multi, + ) + + # Clean up FSM state + if state and is_multi: + await state.update_data(revoke_sub_id=None) + + await callback.message.edit_text( + texts.t( + 'SUBSCRIPTION_REVOKE_SUCCESS', + ( + '✅ Подписка перевыпущена!\n\n' + 'Новая ссылка подключения готова. ' + 'Старая ссылка больше не действительна.\n\n' + 'Все устройства были отключены.' + ), + ), + reply_markup=_build_revoke_success_keyboard(db_user.language, multi_tariff=is_multi), + parse_mode='HTML', + ) + + +# --------------------------------------------------------------------------- +# Multi-tariff mode +# --------------------------------------------------------------------------- + + +@error_handler +async def start_multi_revoke( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +) -> None: + """Show revoke confirmation for multi-tariff mode (callback_data = 'sr:{sub_id}').""" + if isinstance(callback.message, InaccessibleMessage): + await callback.answer() + return + + texts = get_texts(db_user.language) + + if not settings.is_subscription_revoke_enabled(): + await callback.answer( + texts.t('SUBSCRIPTION_REVOKE_DISABLED', 'Перевыпуск подписки недоступен'), + show_alert=True, + ) + return + + # Extract sub_id from callback_data + parts = (callback.data or '').split(':') + if len(parts) < 2: + await callback.answer('Неверный формат', show_alert=True) + return + + try: + sub_id = int(parts[1]) + except (ValueError, TypeError): + await callback.answer('Неверный формат', show_alert=True) + return + + # Validate ownership (IDOR protection) + subscription = await get_subscription_by_id_for_user(db, sub_id, db_user.id) + if not subscription: + await callback.answer( + texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'), + show_alert=True, + ) + return + + if not subscription.is_active: + await callback.answer( + texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'), + show_alert=True, + ) + return + + # Check cooldown + remaining = _check_revoke_cooldown(subscription) + if remaining is not None: + minutes = remaining // 60 + seconds = remaining % 60 + await callback.answer( + texts.t( + 'SUBSCRIPTION_REVOKE_COOLDOWN', + '⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.', + ).format(minutes=minutes, seconds=seconds), + show_alert=True, + ) + return + + # Store sub_id in FSM state for the confirmation handler + await state.update_data(revoke_sub_id=sub_id) + + await callback.answer() + + await callback.message.edit_text( + texts.t( + 'SUBSCRIPTION_REVOKE_WARNING', + ( + '⚠️ Перевыпуск подписки\n\n' + 'Это действие:\n' + '• Сгенерирует новую ссылку подключения\n' + '• Сбросит все подключённые устройства\n' + '• Старая ссылка перестанет работать\n\n' + 'Продолжить?' + ), + ), + reply_markup=_build_revoke_confirm_keyboard(db_user.language, multi_tariff=True), + parse_mode='HTML', + ) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 5b6dc0d9..337453a3 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -8,6 +8,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import PERIOD_PRICES, settings from app.database.models import User +from app.handlers.subscription.common import ( + build_redirect_link, + create_deep_link, + get_localized_value, + resolve_button_url, +) from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts from app.utils.miniapp_buttons import build_miniapp_or_callback_button @@ -1844,7 +1850,35 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN ) has_direct_payment_methods = True - if settings.is_aurapay_enabled(): + if settings.is_aurapay_sbp_enabled(): + sbp_name = settings.get_aurapay_sbp_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_AURAPAY_SBP', f'📱 {sbp_name}'), + callback_data=_build_callback('aurapay_sbp'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_aurapay_card_enabled(): + card_name = settings.get_aurapay_card_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_AURAPAY_CARD', f'💳 {card_name}'), + callback_data=_build_callback('aurapay_card'), + ) + ] + ) + has_direct_payment_methods = True + + if ( + settings.is_aurapay_enabled() + and not settings.is_aurapay_sbp_enabled() + and not settings.is_aurapay_card_enabled() + ): aurapay_name = settings.get_aurapay_display_name() keyboard.append( [ @@ -1856,6 +1890,212 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN ) has_direct_payment_methods = True + if settings.is_etoplatezhi_sbp_enabled(): + sbp_name = settings.get_etoplatezhi_sbp_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_ETOPLATEZHI_SBP', f'📱 {sbp_name}'), + callback_data=_build_callback('etoplatezhi_sbp'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_etoplatezhi_card_enabled(): + card_name = settings.get_etoplatezhi_card_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_ETOPLATEZHI_CARD', f'💳 {card_name}'), + callback_data=_build_callback('etoplatezhi_card'), + ) + ] + ) + has_direct_payment_methods = True + + if ( + settings.is_etoplatezhi_enabled() + and not settings.is_etoplatezhi_sbp_enabled() + and not settings.is_etoplatezhi_card_enabled() + ): + etoplatezhi_name = settings.get_etoplatezhi_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_ETOPLATEZHI', f'💳 {etoplatezhi_name}'), + callback_data=_build_callback('etoplatezhi'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_antilopay_sbp_enabled(): + sbp_name = settings.get_antilopay_sbp_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_ANTILOPAY_SBP', f'📱 {sbp_name}'), + callback_data=_build_callback('antilopay_sbp'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_antilopay_card_enabled(): + card_name = settings.get_antilopay_card_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_ANTILOPAY_CARD', f'💳 {card_name}'), + callback_data=_build_callback('antilopay_card'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_antilopay_sberpay_enabled(): + sberpay_name = settings.get_antilopay_sberpay_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_ANTILOPAY_SBERPAY', f'💳 {sberpay_name}'), + callback_data=_build_callback('antilopay_sberpay'), + ) + ] + ) + has_direct_payment_methods = True + + if ( + settings.is_antilopay_enabled() + and not settings.is_antilopay_sbp_enabled() + and not settings.is_antilopay_card_enabled() + and not settings.is_antilopay_sberpay_enabled() + ): + antilopay_name = settings.get_antilopay_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_ANTILOPAY', f'💳 {antilopay_name}'), + callback_data=_build_callback('antilopay'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_jupiter_sbp_enabled(): + jupiter_sbp_name = settings.get_jupiter_sbp_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_JUPITER_SBP', f'📱 {jupiter_sbp_name}'), + callback_data=_build_callback('jupiter_sbp'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_jupiter_enabled() and not settings.is_jupiter_sbp_enabled(): + jupiter_name = settings.get_jupiter_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_JUPITER', f'🪐 {jupiter_name}'), + callback_data=_build_callback('jupiter'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_donut_card_enabled(): + donut_card_name = settings.get_donut_card_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_DONUT_CARD', f'💳 {donut_card_name}'), + callback_data=_build_callback('donut_card'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_donut_sbp_enabled(): + donut_sbp_name = settings.get_donut_sbp_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_DONUT_SBP', f'📱 {donut_sbp_name}'), + callback_data=_build_callback('donut_sbp'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_donut_sbp_qr_enabled(): + donut_qr_name = settings.get_donut_sbp_qr_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_DONUT_SBP_QR', f'🏦 {donut_qr_name}'), + callback_data=_build_callback('donut_sbp_qr'), + ) + ] + ) + has_direct_payment_methods = True + + if ( + settings.is_donut_enabled() + and not settings.is_donut_card_enabled() + and not settings.is_donut_sbp_enabled() + and not settings.is_donut_sbp_qr_enabled() + ): + donut_name = settings.get_donut_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_DONUT', f'🍩 {donut_name}'), + callback_data=_build_callback('donut'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_lava_card_enabled(): + lava_card_name = settings.get_lava_card_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_LAVA_CARD', f'💳 {lava_card_name}'), + callback_data=_build_callback('lava_card'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_lava_sbp_enabled(): + lava_sbp_name = settings.get_lava_sbp_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_LAVA_SBP', f'📱 {lava_sbp_name}'), + callback_data=_build_callback('lava_sbp'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_lava_enabled() and not settings.is_lava_card_enabled() and not settings.is_lava_sbp_enabled(): + lava_name = settings.get_lava_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_LAVA', f'🌋 {lava_name}'), + callback_data=_build_callback('lava'), + ) + ] + ) + has_direct_payment_methods = True + if settings.is_support_topup_enabled(): keyboard.append( [ @@ -2554,9 +2794,6 @@ def get_device_selection_keyboard( platforms: list[dict] | None = None, sub_id: int | None = None, ) -> InlineKeyboardMarkup: - from app.config import settings - from app.handlers.subscription.common import get_localized_value - texts = get_texts(language) back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription' @@ -2609,8 +2846,6 @@ def get_connection_guide_keyboard( has_other_apps: bool = False, sub_id: int | None = None, ) -> InlineKeyboardMarkup: - from app.handlers.subscription.common import create_deep_link, get_localized_value, resolve_button_url - texts = get_texts(language) back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription' @@ -2623,6 +2858,10 @@ def get_connection_guide_keyboard( if not isinstance(btn, dict): continue btn_type = btn.get('type', '') + # Support both 'external' and 'externalLink' for backward compatibility + if btn_type == 'external': + btn_type = 'externalLink' + btn_text = btn.get('text', {}) if isinstance(btn_text, dict): btn_text = get_localized_value(btn_text, language) @@ -2644,9 +2883,26 @@ def get_connection_guide_keyboard( ] ) elif btn_type == 'subscriptionLink': + # First try to resolve the button's URL template url = resolved_url or resolve_button_url(btn_url, subscription_url) - deep_link = create_deep_link(app.get('_raw', app), subscription_url) - final_url = deep_link or url or subscription_url + + # If button has no template, try deep link + if not btn_url or '{{SUBSCRIPTION_LINK}}' not in btn_url: + deep_link = create_deep_link(app.get('_raw', app), subscription_url) + final_url = deep_link or url or subscription_url + else: + final_url = url or subscription_url + + # Telegram doesn't support custom URL schemes — wrap with redirect + if final_url and not final_url.startswith(('http://', 'https://')): + template = settings.get_happ_cryptolink_redirect_template() + if template: + wrapped_url = build_redirect_link(final_url, template) + if wrapped_url: + final_url = wrapped_url + else: + final_url = subscription_url + if final_url: keyboard.append( [ @@ -2976,6 +3232,16 @@ def get_updated_subscription_settings_keyboard( ] ) + if settings.is_subscription_revoke_enabled(): + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('SUBSCRIPTION_REVOKE_BTN', '🔄 Перевыпустить подписку'), + callback_data='subscription_revoke', + ) + ] + ) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]) return InlineKeyboardMarkup(inline_keyboard=keyboard) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index c61740f7..ac6bf068 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1758,5 +1758,15 @@ "WEBHOOK_TORRENT_DETECTED": "🚫 Torrent detected\n\nTorrent traffic was detected on your connection{tariff_label}. Using torrents may result in subscription restrictions.", "WEBHOOK_CLOSE_BUTTON": "✖️ Close", "TRAFFIC_WARNING_ALERT": "⚠️ Traffic Warning\n\nUsed: {used:.1f} / {limit} GB ({percent:.0f}%)\n\nYour traffic limit is almost reached.", - "LOW_BALANCE_ALERT": "⚠️ Low Balance\n\nYour balance: {balance} ₽\nNotification threshold: {threshold} ₽\n\nTop up your balance to ensure automatic subscription renewal." + "LOW_BALANCE_ALERT": "⚠️ Low Balance\n\nYour balance: {balance} ₽\nNotification threshold: {threshold} ₽\n\nTop up your balance to ensure automatic subscription renewal.", + + "SUBSCRIPTION_REVOKE_BTN": "🔄 Reissue Subscription", + "SUBSCRIPTION_REVOKE_TITLE": "⚠️ Reissue Subscription", + "SUBSCRIPTION_REVOKE_WARNING": "⚠️ Reissue Subscription\n\nThis action will:\n• Generate a new connection link\n• Disconnect all devices\n• The old link will stop working\n\nContinue?", + "SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Confirm", + "SUBSCRIPTION_REVOKE_SUCCESS": "✅ Subscription reissued!\n\nYour new connection link is ready. The old link is no longer valid.\n\nAll devices have been disconnected.", + "SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Reissue will be available in {minutes} min {seconds} sec.", + "SUBSCRIPTION_REVOKE_DISABLED": "Subscription reissue is not available", + "SUBSCRIPTION_REVOKE_ERROR": "❌ Error reissuing subscription. Please try again later.", + "SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Connect" } \ No newline at end of file diff --git a/app/localization/locales/fa.json b/app/localization/locales/fa.json index cce8be55..67fd6eab 100644 --- a/app/localization/locales/fa.json +++ b/app/localization/locales/fa.json @@ -1779,5 +1779,15 @@ "WEBHOOK_TORRENT_DETECTED": "🚫 تورنت شناسایی شد\n\nترافیک تورنت در اتصال{tariff_label} شما شناسایی شد. استفاده از تورنت ممکن است منجر به محدودیت اشتراک شود.", "WEBHOOK_CLOSE_BUTTON": "✖️ بستن", "TRAFFIC_WARNING_ALERT": "⚠️ هشدار ترافیک\n\nاستفاده شده: {used:.1f} / {limit} گیگابایت ({percent:.0f}%)\n\nحد ترافیک شما تقریباً تمام شده است.", - "LOW_BALANCE_ALERT": "⚠️ موجودی کم\n\nموجودی شما: {balance} ₽\nآستانه اطلاع‌رسانی: {threshold} ₽\n\nموجودی خود را شارژ کنید تا تمدید خودکار اشتراک با موفقیت انجام شود." + "LOW_BALANCE_ALERT": "⚠️ موجودی کم\n\nموجودی شما: {balance} ₽\nآستانه اطلاع‌رسانی: {threshold} ₽\n\nموجودی خود را شارژ کنید تا تمدید خودکار اشتراک با موفقیت انجام شود.", + + "SUBSCRIPTION_REVOKE_BTN": "🔄 صدور مجدد اشتراک", + "SUBSCRIPTION_REVOKE_TITLE": "⚠️ صدور مجدد اشتراک", + "SUBSCRIPTION_REVOKE_WARNING": "⚠️ صدور مجدد اشتراک\n\nاین عمل:\n• لینک اتصال جدیدی تولید می‌کند\n• تمام دستگاه‌های متصل را قطع می‌کند\n• لینک قدیمی دیگر کار نخواهد کرد\n\nادامه می‌دهید؟", + "SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ تأیید", + "SUBSCRIPTION_REVOKE_SUCCESS": "✅ اشتراک مجدداً صادر شد!\n\nلینک اتصال جدید آماده است. لینک قدیمی دیگر معتبر نیست.\n\nتمام دستگاه‌ها قطع شدند.", + "SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ صدور مجدد {minutes} دقیقه و {seconds} ثانیه دیگر در دسترس خواهد بود.", + "SUBSCRIPTION_REVOKE_DISABLED": "صدور مجدد اشتراک در دسترس نیست", + "SUBSCRIPTION_REVOKE_ERROR": "❌ خطا در صدور مجدد اشتراک. لطفاً بعداً دوباره امتحان کنید.", + "SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 اتصال" } \ No newline at end of file diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index c75fecfe..61c7c540 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1779,5 +1779,15 @@ "WEBHOOK_TORRENT_DETECTED": "🚫 Обнаружен торрент\n\nВ вашем подключении{tariff_label} обнаружен торрент-трафик. Использование торрентов может привести к ограничению подписки.", "WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть", "TRAFFIC_WARNING_ALERT": "⚠️ Предупреждение о трафике\n\nИспользовано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш лимит трафика почти исчерпан.", - "LOW_BALANCE_ALERT": "⚠️ Низкий баланс\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно." + "LOW_BALANCE_ALERT": "⚠️ Низкий баланс\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно.", + + "SUBSCRIPTION_REVOKE_BTN": "🔄 Перевыпустить подписку", + "SUBSCRIPTION_REVOKE_TITLE": "⚠️ Перевыпуск подписки", + "SUBSCRIPTION_REVOKE_WARNING": "⚠️ Перевыпуск подписки\n\nЭто действие:\n• Сгенерирует новую ссылку подключения\n• Сбросит все подключённые устройства\n• Старая ссылка перестанет работать\n\nПродолжить?", + "SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Подтвердить", + "SUBSCRIPTION_REVOKE_SUCCESS": "✅ Подписка перевыпущена!\n\nНовая ссылка подключения готова. Старая ссылка больше не действительна.\n\nВсе устройства были отключены.", + "SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.", + "SUBSCRIPTION_REVOKE_DISABLED": "Перевыпуск подписки недоступен", + "SUBSCRIPTION_REVOKE_ERROR": "❌ Ошибка при перевыпуске подписки. Попробуйте позже.", + "SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Подключиться" } \ No newline at end of file diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 663ddacb..8183c7ae 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -1650,5 +1650,15 @@ "WEBHOOK_TORRENT_DETECTED": "🚫 Виявлено торент\n\nУ вашому підключенні{tariff_label} виявлено торент-трафік. Використання торентів може призвести до обмеження підписки.", "WEBHOOK_CLOSE_BUTTON": "✖️ Закрити", "TRAFFIC_WARNING_ALERT": "⚠️ Попередження про трафік\n\nВикористано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш ліміт трафіку майже вичерпаний.", - "LOW_BALANCE_ALERT": "⚠️ Низький баланс\n\nВаш баланс: {balance} ₽\nПоріг сповіщення: {threshold} ₽\n\nПоповніть баланс, щоб автопродовження підписки пройшло успішно." + "LOW_BALANCE_ALERT": "⚠️ Низький баланс\n\nВаш баланс: {balance} ₽\nПоріг сповіщення: {threshold} ₽\n\nПоповніть баланс, щоб автопродовження підписки пройшло успішно.", + + "SUBSCRIPTION_REVOKE_BTN": "🔄 Перевипустити підписку", + "SUBSCRIPTION_REVOKE_TITLE": "⚠️ Перевипуск підписки", + "SUBSCRIPTION_REVOKE_WARNING": "⚠️ Перевипуск підписки\n\nЦя дія:\n• Згенерує нове посилання підключення\n• Скине всі підключені пристрої\n• Старе посилання перестане працювати\n\nПродовжити?", + "SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Підтвердити", + "SUBSCRIPTION_REVOKE_SUCCESS": "✅ Підписку перевипущено!\n\nНове посилання підключення готове. Старе посилання більше не дійсне.\n\nВсі пристрої були відключені.", + "SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Перевипуск буде доступний через {minutes} хв. {seconds} сек.", + "SUBSCRIPTION_REVOKE_DISABLED": "Перевипуск підписки недоступний", + "SUBSCRIPTION_REVOKE_ERROR": "❌ Помилка при перевипуску підписки. Спробуйте пізніше.", + "SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Підключитися" } \ No newline at end of file diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 77de6679..5dc49f89 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -1648,5 +1648,15 @@ "BALANCE_TOPPED_UP_CART_SUFFICIENT": "✅ 余额已充值 {amount}!\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n余额足够完成订购。", "BALANCE_TOPPED_UP_CART_INSUFFICIENT": "✅ 余额已充值 {amount}!\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n还差:{missing}", "TRAFFIC_WARNING_ALERT": "⚠️ 流量警告\n\n已使用:{used:.1f} / {limit} GB ({percent:.0f}%)\n\n您的流量限制即将用完。", - "LOW_BALANCE_ALERT": "⚠️ 余额不足\n\n您的余额:{balance} ₽\n通知阈值:{threshold} ₽\n\n请充值以确保订阅自动续费成功。" + "LOW_BALANCE_ALERT": "⚠️ 余额不足\n\n您的余额:{balance} ₽\n通知阈值:{threshold} ₽\n\n请充值以确保订阅自动续费成功。", + + "SUBSCRIPTION_REVOKE_BTN": "🔄 重新签发订阅", + "SUBSCRIPTION_REVOKE_TITLE": "⚠️ 重新签发订阅", + "SUBSCRIPTION_REVOKE_WARNING": "⚠️ 重新签发订阅\n\n此操作将:\n• 生成新的连接链接\n• 断开所有已连接的设备\n• 旧链接将失效\n\n是否继续?", + "SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ 确认", + "SUBSCRIPTION_REVOKE_SUCCESS": "✅ 订阅已重新签发!\n\n新的连接链接已准备就绪。旧链接已失效。\n\n所有设备已断开连接。", + "SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ 重新签发将在 {minutes} 分 {seconds} 秒后可用。", + "SUBSCRIPTION_REVOKE_DISABLED": "订阅重新签发不可用", + "SUBSCRIPTION_REVOKE_ERROR": "❌ 重新签发订阅时出错。请稍后重试。", + "SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 连接" } \ No newline at end of file diff --git a/app/services/antilopay_service.py b/app/services/antilopay_service.py new file mode 100644 index 00000000..153a2b99 --- /dev/null +++ b/app/services/antilopay_service.py @@ -0,0 +1,246 @@ +"""Сервис для работы с API Antilopay (lk.antilopay.com/api/v2).""" + +import base64 +import json +from typing import Any + +import aiohttp +import structlog +from Crypto.Hash import SHA256 +from Crypto.PublicKey import RSA +from Crypto.Signature import pkcs1_15 + +from app.config import settings + + +logger = structlog.get_logger(__name__) + +API_BASE_URL = 'https://lk.antilopay.com/api/v2' + + +class AntilopayAPIError(Exception): + """Ошибка API Antilopay.""" + + def __init__(self, status_code: int, message: str, code: int | None = None): + self.status_code = status_code + self.message = message + self.api_code = code + super().__init__(f'Antilopay API error ({status_code}): {message}') + + +class AntilopayService: + """Сервис для работы с API Antilopay.""" + + def __init__(self) -> None: + self._session: aiohttp.ClientSession | None = None + + @property + def secret_id(self) -> str: + return settings.ANTILOPAY_SECRET_ID or '' + + @property + def private_key(self) -> str: + return settings.ANTILOPAY_PRIVATE_KEY or '' + + @property + def public_key(self) -> str: + return settings.ANTILOPAY_PUBLIC_KEY or '' + + @property + def project_id(self) -> str: + return settings.ANTILOPAY_PROJECT_ID or '' + + async def _get_session(self) -> aiohttp.ClientSession: + """Возвращает переиспользуемую HTTP-сессию.""" + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30), + ) + return self._session + + async def close(self) -> None: + """Закрывает HTTP-сессию.""" + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + def _sign_request(self, json_body: str) -> str: + """SHA256WithRSA подпись JSON body приватным ключом. + + Результат — base64-encoded строка. + """ + rsa_key = RSA.import_key(base64.b64decode(self.private_key)) + h = SHA256.new(json_body.encode('UTF-8')) + signature = pkcs1_15.new(rsa_key).sign(h) + return base64.b64encode(signature).decode('UTF-8') + + def _build_headers(self, json_body: str) -> dict[str, str]: + """Строит заголовки запроса с подписью.""" + return { + 'Content-Type': 'application/json', + 'X-Apay-Secret-Id': self.secret_id, + 'X-Apay-Sign': self._sign_request(json_body), + 'X-Apay-Sign-Version': '1', + } + + async def create_payment( + self, + *, + amount_rubles: float, + order_id: str, + product_name: str, + product_type: str = 'services', + description: str = '', + customer_email: str | None = None, + customer_phone: str | None = None, + prefer_methods: list[str] | None = None, + success_url: str | None = None, + fail_url: str | None = None, + merchant_extra: str | None = None, + ) -> dict[str, Any]: + """ + Создает платеж через API Antilopay. + POST /payment/create + """ + payload: dict[str, Any] = { + 'project_identificator': self.project_id, + 'amount': amount_rubles, + 'order_id': order_id, + 'currency': settings.ANTILOPAY_CURRENCY.lower(), + 'product_name': product_name, + 'product_type': product_type, + 'description': description, + } + + # customer — обязательное поле, нужен email или phone + customer: dict[str, str] = {} + if customer_email: + customer['email'] = customer_email + if customer_phone: + customer['phone'] = customer_phone + if not customer: + # Fallback email, чтобы API не отказал + customer['email'] = 'user@vpn.bot' + payload['customer'] = customer + + if prefer_methods: + payload['prefer_methods'] = prefer_methods + if success_url: + payload['success_url'] = success_url + if fail_url: + payload['fail_url'] = fail_url + if merchant_extra: + payload['merchant_extra'] = merchant_extra[:255] + + json_body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False) + + logger.info( + 'Antilopay API create_payment', + order_id=order_id, + amount_rubles=amount_rubles, + prefer_methods=prefer_methods, + ) + + try: + session = await self._get_session() + async with session.post( + f'{API_BASE_URL}/payment/create', + data=json_body, + headers=self._build_headers(json_body), + ) as response: + data = await response.json(content_type=None) + + api_code = data.get('code') + if response.status == 200 and api_code == 0: + logger.info( + 'Antilopay API payment created', + order_id=order_id, + payment_id=data.get('payment_id'), + payment_url=data.get('payment_url'), + ) + return data + + error_msg = data.get('message') or data.get('error') or str(data) + logger.error( + 'Antilopay create_payment error', + status_code=response.status, + api_code=api_code, + error_msg=error_msg, + response_data=data, + ) + raise AntilopayAPIError(response.status, error_msg, api_code) + + except aiohttp.ClientError as e: + logger.exception('Antilopay API connection error', error=e) + raise + + async def check_payment( + self, + *, + order_id: str, + ) -> dict[str, Any]: + """ + Проверяет статус платежа. + POST /payment/check + """ + payload: dict[str, Any] = { + 'project_identificator': self.project_id, + 'order_id': order_id, + } + + json_body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False) + + logger.info('Antilopay check_payment', order_id=order_id) + + try: + session = await self._get_session() + async with session.post( + f'{API_BASE_URL}/payment/check', + data=json_body, + headers=self._build_headers(json_body), + ) as response: + data = await response.json(content_type=None) + + if response.status == 200: + return data + + error_msg = data.get('message') or data.get('error') or str(data) + logger.error( + 'Antilopay check_payment error', + status_code=response.status, + error_msg=error_msg, + ) + raise AntilopayAPIError(response.status, error_msg) + + except aiohttp.ClientError as e: + logger.exception('Antilopay API connection error', error=e) + raise + + def verify_callback_signature(self, raw_body: bytes, received_signature: str) -> bool: + """Верификация подписи callback Antilopay через SHA256WithRSA. + + Подпись приходит в заголовке X-Apay-Callback. + Проверяется ПУБЛИЧНЫМ ключом. + """ + try: + if not received_signature: + logger.warning('Antilopay callback: отсутствует X-Apay-Callback') + return False + + rsa_key = RSA.import_key(base64.b64decode(self.public_key)) + h = SHA256.new(raw_body) + signature_bytes = base64.b64decode(received_signature) + + pkcs1_15.new(rsa_key).verify(h, signature_bytes) + return True + + except (ValueError, TypeError) as e: + logger.warning('Antilopay callback: invalid signature', error=str(e)) + return False + except Exception as e: + logger.error('Antilopay callback verify error', error=e) + return False + + +# Singleton instance +antilopay_service = AntilopayService() diff --git a/app/services/aurapay_service.py b/app/services/aurapay_service.py index 2dde0457..b62767b3 100644 --- a/app/services/aurapay_service.py +++ b/app/services/aurapay_service.py @@ -197,8 +197,9 @@ class AuraPayService: return False # Сортируем ключи по алфавиту и конкатенируем значения + # None → '' (PHP implode() converts null to empty string, not "None") sorted_keys = sorted(payload.keys()) - concatenated_values = ''.join(str(payload[key]) for key in sorted_keys) + concatenated_values = ''.join(str(payload[key]) if payload[key] is not None else '' for key in sorted_keys) expected = hmac.new( self.secret_key.encode('utf-8'), diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 2d3759d1..28ec6bcf 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -31,6 +31,7 @@ from app.database.models import ( AdminRole, AdvertisingCampaign, AdvertisingCampaignRegistration, + AppleTransaction, AuraPayPayment, BroadcastHistory, ButtonClickLog, @@ -204,6 +205,7 @@ class BackupService: RollyPayPayment, OverpayPayment, AuraPayPayment, + AppleTransaction, SavedPaymentMethod, # --- Settings/content --- PaymentMethodConfig, @@ -1515,6 +1517,9 @@ class BackupService: 'rollypay_payments', 'overpay_payments', 'aurapay_payments', + 'etoplatezhi_payments', + 'antilopay_payments', + 'apple_transactions', 'saved_payment_methods', # --- Content/config --- 'pinned_messages', diff --git a/app/services/donut_service.py b/app/services/donut_service.py new file mode 100644 index 00000000..80c7c2a5 --- /dev/null +++ b/app/services/donut_service.py @@ -0,0 +1,266 @@ +"""Сервис для работы с API Donut (Donut P2P, gw.donut.business).""" + +import hashlib +import hmac +import json +from typing import Any + +import aiohttp +import structlog + +from app.config import settings + + +logger = structlog.get_logger(__name__) + + +class DonutAPIError(Exception): + """Ошибка API Donut.""" + + def __init__(self, status_code: int, message: str, code: str | None = None) -> None: + self.status_code = status_code + self.message = message + self.api_code = code + super().__init__(f'Donut API error ({status_code}): {message}') + + +class DonutService: + """Клиент для Donut P2P (gw.donut.business).""" + + def __init__(self) -> None: + self._session: aiohttp.ClientSession | None = None + + @property + def base_url(self) -> str: + return (settings.DONUT_BASE_URL or 'https://gw.donut.business').rstrip('/') + + @property + def token(self) -> str: + return settings.DONUT_TOKEN or '' + + @property + def secret(self) -> str: + return settings.DONUT_SECRET or '' + + @property + def method_id(self) -> str | None: + value = (settings.DONUT_METHOD_ID or '').strip() + return value or None + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30), + ) + return self._session + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + @staticmethod + def _build_signature_string(parts: list[tuple[str, Any]]) -> str: + """Собирает каноническую строку для подписи: имя=значение (без разделителя).""" + chunks: list[str] = [] + for key, value in parts: + if value is None: + continue + if isinstance(value, bool): + chunks.append(f'{key}={"true" if value else "false"}') + else: + value_str = str(value) + if value_str == '': + continue + chunks.append(f'{key}={value_str}') + return ''.join(chunks) + + def _hmac_hex(self, message: str) -> str: + """HMAC-SHA256 в hex.""" + return hmac.new( + self.secret.encode('utf-8'), + msg=message.encode('utf-8'), + digestmod=hashlib.sha256, + ).hexdigest() + + def _sign_payin(self, payload: dict[str, Any]) -> str: + amount = payload['amount'] + customer = payload['customer'] + parts: list[tuple[str, Any]] = [ + ('token', payload['token']), + ('order_id', payload['order_id']), + ('amount.value', amount['value']), + ('amount.currency', amount['currency']), + ('customer.id', customer['id']), + ('redirect', payload['redirect']), + ] + return self._hmac_hex(self._build_signature_string(parts)) + + def _sign_status(self, payload: dict[str, Any]) -> str: + parts: list[tuple[str, Any]] = [ + ('token', payload['token']), + ('transaction_id', payload['transaction_id']), + ] + return self._hmac_hex(self._build_signature_string(parts)) + + def _sign_balance(self, payload: dict[str, Any]) -> str: + parts: list[tuple[str, Any]] = [ + ('token', payload['token']), + ] + return self._hmac_hex(self._build_signature_string(parts)) + + @staticmethod + def _format_amount(amount_rubles: float) -> str: + """Сумма строго '0.00' с точкой (требование Donut P2P).""" + return f'{float(amount_rubles):.2f}' + + async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + url = f'{self.base_url}/{path.lstrip("/")}' + body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False) + try: + session = await self._get_session() + async with session.post( + url, + data=body, + headers={'Content-Type': 'application/json'}, + ) as response: + data = await response.json(content_type=None) + return data if isinstance(data, dict) else {'_raw': data} + except aiohttp.ClientError as error: + logger.exception('Donut API connection error', url=url, error=error) + raise + + async def create_payment( + self, + *, + amount_rubles: float, + order_id: str, + customer_id: str, + method_description: str, + customer_email: str | None = None, + customer_phone: str | None = None, + callback_url: str | None = None, + return_url: str | None = None, + receipt: str | None = None, + redirect: bool = True, + ) -> dict[str, Any]: + """Создаёт платёж (PayIn) через Donut P2P. + + POST /p2p_payin + """ + payload: dict[str, Any] = { + 'token': self.token, + 'order_id': order_id, + 'amount': { + 'value': self._format_amount(amount_rubles), + 'currency': (settings.DONUT_CURRENCY or 'RUB').upper(), + }, + 'customer': {'id': str(customer_id)}, + 'redirect': 'true' if redirect else 'false', + 'description': method_description, + } + + if customer_email: + payload['customer']['email'] = customer_email + if customer_phone: + payload['customer']['phone'] = customer_phone + + if self.method_id: + payload['method_id'] = self.method_id + if callback_url: + payload['callback_url'] = callback_url + if return_url: + payload['return_url'] = return_url + if receipt: + payload['receipt'] = receipt[:255] + + payload['signature'] = self._sign_payin(payload) + + logger.info( + 'Donut API create_payment', + order_id=order_id, + amount_rubles=amount_rubles, + description=method_description, + ) + + data = await self._post('/p2p_payin', payload) + status_obj = (data.get('status') or {}) if isinstance(data, dict) else {} + status_type = status_obj.get('type') + + if status_type in ('processing', 'success', 'created'): + logger.info( + 'Donut API payment created', + order_id=order_id, + transaction_id=data.get('transaction_id'), + status_type=status_type, + ) + return data + + error_code = status_obj.get('error_code') or '0' + error_msg = status_obj.get('error_description') or status_obj.get('message') or 'Unknown error' + logger.error( + 'Donut create_payment error', + error_code=error_code, + error_msg=error_msg, + response_data=data, + ) + raise DonutAPIError(200, error_msg, error_code) + + async def check_payment(self, *, transaction_id: str) -> dict[str, Any]: + """Получает статус платежа. + + POST /p2p_status + """ + payload: dict[str, Any] = { + 'token': self.token, + 'transaction_id': str(transaction_id), + } + payload['signature'] = self._sign_status(payload) + + logger.info('Donut check_payment', transaction_id=transaction_id) + return await self._post('/p2p_status', payload) + + async def get_balance(self) -> dict[str, Any]: + """Получает баланс продавца. + + POST /p2p_balance + """ + payload: dict[str, Any] = {'token': self.token} + payload['signature'] = self._sign_balance(payload) + return await self._post('/p2p_balance', payload) + + def verify_callback_signature(self, payload: dict[str, Any]) -> bool: + """Верификация подписи callback (HMAC-SHA256, hex).""" + try: + received = (payload.get('signature') or '').strip() + if not received: + logger.warning('Donut callback: отсутствует signature') + return False + + amount = payload.get('amount') or {} + status_obj = payload.get('status') or {} + parts: list[tuple[str, Any]] = [ + ('token', payload.get('token')), + ('transaction_id', payload.get('transaction_id')), + ('order_id', payload.get('order_id')), + ('amount.value', amount.get('value')), + ('amount.currency', amount.get('currency')), + ('recalculated', payload.get('recalculated')), + ('status.type', status_obj.get('type')), + ] + expected = self._hmac_hex(self._build_signature_string(parts)) + if not hmac.compare_digest(expected.lower(), received.lower()): + logger.warning( + 'Donut callback: invalid signature', + expected_prefix=expected[:8], + received_prefix=received[:8], + ) + return False + return True + except Exception as error: + logger.error('Donut callback verify error', error=error) + return False + + +# Singleton instance +donut_service = DonutService() diff --git a/app/services/etoplatezhi_service.py b/app/services/etoplatezhi_service.py new file mode 100644 index 00000000..edba3b7f --- /dev/null +++ b/app/services/etoplatezhi_service.py @@ -0,0 +1,202 @@ +"""Сервис для работы с Etoplatezhi (paymentpage.etoplatezhi.ru).""" + +import base64 +import hashlib +import hmac +from typing import Any +from urllib.parse import urlencode + +import structlog + +from app.config import settings + + +logger = structlog.get_logger(__name__) + +PAYMENT_PAGE_BASE_URL = 'https://paymentpage.etoplatezhi.ru/payment' + + +class EtoplatezhiService: + """Сервис для построения URL платежей и верификации callback-ов Etoplatezhi.""" + + @property + def project_id(self) -> int: + return settings.ETOPLATEZHI_PROJECT_ID or 0 + + @property + def secret_key(self) -> str: + return settings.ETOPLATEZHI_SECRET_KEY or '' + + def _flatten_params( + self, + params: dict[str, Any], + prefix: str = '', + ignore: set[str] | None = None, + ) -> list[str]: + """Рекурсивно «сплющивает» вложенные словари в список 'key:value' строк. + + Keys разделяются двоеточием. ``frame_mode`` и ``signature`` игнорируются. + Booleans приводятся к '1'/'0'. + Empty arrays (lists) are excluded entirely per Etoplatezhi spec. + """ + if ignore is None: + ignore = {'frame_mode', 'signature'} + + entries: list[str] = [] + for key, value in params.items(): + full_key = f'{prefix}:{key}' if prefix else key + if full_key in ignore or key in ignore: + continue + + if isinstance(value, dict): + entries.extend(self._flatten_params(value, prefix=full_key, ignore=ignore)) + elif isinstance(value, list): + # Empty arrays are excluded entirely per spec + if not value: + continue + # Non-empty arrays: flatten each element with index as key + for idx, item in enumerate(value): + item_key = f'{full_key}:{idx}' + if isinstance(item, dict): + entries.extend(self._flatten_params(item, prefix=item_key, ignore=ignore)) + elif isinstance(item, bool): + entries.append(f'{item_key}:{"1" if item else "0"}') + elif item is not None: + entries.append(f'{item_key}:{item}') + elif isinstance(value, bool): + entries.append(f'{full_key}:{"1" if value else "0"}') + elif value is not None: + entries.append(f'{full_key}:{value}') + + return entries + + def _sign(self, params: dict[str, Any]) -> str: + """HMAC-SHA512 + base64 подпись параметров. + + Algorithm: + 1. Flatten nested dicts with ':' separator. + 2. Each leaf → "key:value". + 3. Sort alphabetically by full key string. + 4. Join with ';'. + 5. HMAC-SHA512 with secret_key. + 6. base64-encode the raw digest. + """ + entries = self._flatten_params(params) + entries.sort() + message = ';'.join(entries) + + digest = hmac.new( + self.secret_key.encode('utf-8'), + message.encode('utf-8'), + hashlib.sha512, + ).digest() + + return base64.b64encode(digest).decode('utf-8') + + def build_payment_url( + self, + *, + project_id: int, + payment_id: str, + payment_amount: int, + payment_currency: str = 'RUB', + customer_id: str, + description: str | None = None, + callback_url: str | None = None, + success_url: str | None = None, + fail_url: str | None = None, + force_payment_method: str | None = None, + customer_email: str | None = None, + language_code: str | None = None, + ) -> str: + """Строит URL для редиректа на платёжную страницу Etoplatezhi. + + Args: + project_id: ID проекта в Etoplatezhi. + payment_id: Наш internal order_id. + payment_amount: Сумма в минорных единицах (копейках). + payment_currency: ISO 4217 код валюты. + customer_id: Telegram ID или guest-идентификатор покупателя. + description: Описание платежа. + callback_url: URL для callback (POST JSON). + success_url: URL редиректа при успехе. + fail_url: URL редиректа при ошибке. + force_payment_method: 'sbp' или 'card' для принудительного выбора. + customer_email: Email покупателя. + language_code: Язык интерфейса ('ru', 'en'). + + Returns: + Полный URL с параметрами и подписью. + """ + params: dict[str, Any] = { + 'project_id': project_id, + 'payment_id': payment_id, + 'payment_amount': payment_amount, + 'payment_currency': payment_currency, + 'customer_id': customer_id, + } + + if description: + params['payment_description'] = description + if callback_url: + params['merchant_callback_url'] = callback_url + if success_url: + params['redirect_success_url'] = success_url + if fail_url: + params['redirect_fail_url'] = fail_url + if force_payment_method: + params['force_payment_method'] = force_payment_method + if customer_email: + params['customer_email'] = customer_email + if language_code: + params['language_code'] = language_code + + params['signature'] = self._sign(params) + + logger.info( + 'Etoplatezhi: building payment URL', + payment_id=payment_id, + payment_amount=payment_amount, + customer_id=customer_id, + ) + + return f'{PAYMENT_PAGE_BASE_URL}?{urlencode(params)}' + + def verify_callback_signature(self, payload: dict[str, Any]) -> bool: + """Верифицирует подпись в callback-е Etoplatezhi. + + Подпись находится внутри JSON body (поле ``signature``). + Для проверки: удаляем ``signature`` из всех уровней вложенности, + вычисляем подпись по оставшимся данным и сравниваем. + """ + try: + received_signature = payload.get('signature') + if not received_signature: + logger.warning('Etoplatezhi callback: отсутствует signature в payload') + return False + + # Deep-copy payload and strip all 'signature' keys recursively + cleaned = self._strip_signature_keys(payload) + + expected = self._sign(cleaned) + return hmac.compare_digest(expected, str(received_signature)) + + except Exception as e: + logger.error('Etoplatezhi callback verify error', error=e) + return False + + def _strip_signature_keys(self, data: dict[str, Any]) -> dict[str, Any]: + """Рекурсивно удаляет ключ ``signature`` из словаря и вложенных словарей.""" + result: dict[str, Any] = {} + for key, value in data.items(): + if key == 'signature': + continue + if isinstance(value, dict): + result[key] = self._strip_signature_keys(value) + else: + result[key] = value + return result + + +# Singleton instance +etoplatezhi_service = EtoplatezhiService() diff --git a/app/services/jupiter_service.py b/app/services/jupiter_service.py new file mode 100644 index 00000000..26482bae --- /dev/null +++ b/app/services/jupiter_service.py @@ -0,0 +1,268 @@ +"""Сервис для работы с API Jupiter (FPGate P2P v2.1, app.juppiter.tech).""" + +import hashlib +import hmac +import json +from typing import Any + +import aiohttp +import structlog + +from app.config import settings + + +logger = structlog.get_logger(__name__) + + +class JupiterAPIError(Exception): + """Ошибка API Jupiter.""" + + def __init__(self, status_code: int, message: str, code: str | None = None) -> None: + self.status_code = status_code + self.message = message + self.api_code = code + super().__init__(f'Jupiter API error ({status_code}): {message}') + + +class JupiterService: + """Клиент для FPGate P2P v2.1 (Jupiter / app.juppiter.tech).""" + + def __init__(self) -> None: + self._session: aiohttp.ClientSession | None = None + + @property + def base_url(self) -> str: + return (settings.JUPITER_BASE_URL or 'https://app.juppiter.tech').rstrip('/') + + @property + def token(self) -> str: + return settings.JUPITER_TOKEN or '' + + @property + def secret(self) -> str: + return settings.JUPITER_SECRET or '' + + @property + def method_id(self) -> str | None: + value = (settings.JUPITER_METHOD_ID or '').strip() + return value or None + + @property + def method_description(self) -> str: + return (settings.JUPITER_METHOD_DESCRIPTION or 'SBP').strip() or 'SBP' + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30), + ) + return self._session + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + @staticmethod + def _build_signature_string(parts: list[tuple[str, Any]]) -> str: + """Собирает каноническую строку для подписи: имя=значение... в порядке полей. + + По спецификации FPGate P2P v2.1: «Если поле подписываемое, но не обязательное, + то оно входит в подпись, если оно присутствует в запросе и имеет непустое значение». + """ + chunks: list[str] = [] + for key, value in parts: + if value is None: + continue + if isinstance(value, bool): + chunks.append(f'{key}={"true" if value else "false"}') + continue + value_str = str(value) + if value_str == '': + continue + chunks.append(f'{key}={value_str}') + return ''.join(chunks) + + def _hmac_hex(self, message: str) -> str: + """HMAC-SHA256 в hex (регистр не важен по спецификации).""" + return hmac.new( + self.secret.encode('utf-8'), + msg=message.encode('utf-8'), + digestmod=hashlib.sha256, + ).hexdigest() + + def _sign_payin(self, payload: dict[str, Any]) -> str: + amount = payload['amount'] + customer = payload['customer'] + parts: list[tuple[str, Any]] = [ + ('token', payload['token']), + ('order_id', payload['order_id']), + ('amount.value', amount['value']), + ('amount.currency', amount['currency']), + ('customer.id', customer['id']), + ('redirect', payload['redirect']), + ] + return self._hmac_hex(self._build_signature_string(parts)) + + def _sign_status(self, payload: dict[str, Any]) -> str: + parts: list[tuple[str, Any]] = [ + ('token', payload['token']), + ('transaction_id', payload['transaction_id']), + ] + return self._hmac_hex(self._build_signature_string(parts)) + + def _sign_balance(self, payload: dict[str, Any]) -> str: + parts: list[tuple[str, Any]] = [ + ('token', payload['token']), + ] + return self._hmac_hex(self._build_signature_string(parts)) + + @staticmethod + def _format_amount(amount_rubles: float) -> str: + """Сумма строго '0.00' с точкой-разделителем (требование P2P v2.1).""" + return f'{float(amount_rubles):.2f}' + + async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + url = f'{self.base_url}/{path.lstrip("/")}' + body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False) + try: + session = await self._get_session() + async with session.post( + url, + data=body, + headers={'Content-Type': 'application/json'}, + ) as response: + data = await response.json(content_type=None) + return data if isinstance(data, dict) else {'_raw': data} + except aiohttp.ClientError as error: + logger.exception('Jupiter API connection error', url=url, error=error) + raise + + async def create_payment( + self, + *, + amount_rubles: float, + order_id: str, + customer_id: str, + customer_email: str | None = None, + customer_phone: str | None = None, + customer_name: str | None = None, + callback_url: str | None = None, + receipt: str | None = None, + description: str | None = None, + ) -> dict[str, Any]: + """Создаёт платёж (PayIn) согласно FPGate P2P v2.1. + + POST /p2p_payin_v2.1 + """ + payload: dict[str, Any] = { + 'token': self.token, + 'order_id': order_id, + 'amount': { + 'value': self._format_amount(amount_rubles), + 'currency': (settings.JUPITER_CURRENCY or 'RUB').upper(), + }, + 'customer': { + 'id': str(customer_id), + 'email': customer_email or settings.JUPITER_FALLBACK_EMAIL or 'user@vpn.bot', + 'phone': customer_phone or settings.JUPITER_FALLBACK_PHONE or '0000000000', + 'name': customer_name or settings.JUPITER_FALLBACK_NAME or 'User', + }, + 'redirect': 'false', + 'description': (description or self.method_description)[:255], + } + + if self.method_id: + payload['method_id'] = self.method_id + if callback_url: + payload['callback_url'] = callback_url + if receipt: + payload['receipt'] = receipt[:255] + + payload['signature'] = self._sign_payin(payload) + + logger.info('Jupiter API create_payment', order_id=order_id, amount_rubles=amount_rubles) + + data = await self._post('/p2p_payin_v2.1', payload) + status = (data.get('status') or {}) if isinstance(data, dict) else {} + status_type = status.get('type') + + if status_type in ('processing', 'success'): + logger.info( + 'Jupiter API payment created', + order_id=order_id, + transaction_id=data.get('transaction_id'), + status_type=status_type, + ) + return data + + error_code = status.get('error_code') or '0' + error_msg = status.get('error_description') or 'Unknown error' + logger.error( + 'Jupiter create_payment error', + error_code=error_code, + error_msg=error_msg, + response_data=data, + ) + raise JupiterAPIError(200, error_msg, error_code) + + async def check_payment(self, *, transaction_id: str) -> dict[str, Any]: + """Получает статус платежа. + + POST /p2p_status_v2.1 + """ + payload: dict[str, Any] = { + 'token': self.token, + 'transaction_id': str(transaction_id), + } + payload['signature'] = self._sign_status(payload) + + logger.info('Jupiter check_payment', transaction_id=transaction_id) + data = await self._post('/p2p_status_v2.1', payload) + return data + + async def get_balance(self) -> dict[str, Any]: + """Получает баланс продавца. + + POST /p2p_balance_v2.1 + """ + payload: dict[str, Any] = {'token': self.token} + payload['signature'] = self._sign_balance(payload) + data = await self._post('/p2p_balance_v2.1', payload) + return data + + def verify_callback_signature(self, payload: dict[str, Any]) -> bool: + """Верификация подписи callback (HMAC-SHA256, hex).""" + try: + received = (payload.get('signature') or '').strip() + if not received: + logger.warning('Jupiter callback: отсутствует signature') + return False + + amount = payload.get('amount') or {} + status = payload.get('status') or {} + parts: list[tuple[str, Any]] = [ + ('token', payload.get('token')), + ('transaction_id', payload.get('transaction_id')), + ('order_id', payload.get('order_id')), + ('amount.value', amount.get('value')), + ('amount.currency', amount.get('currency')), + ('recalculated', payload.get('recalculated')), + ('status.type', status.get('type')), + ] + expected = self._hmac_hex(self._build_signature_string(parts)) + if not hmac.compare_digest(expected.lower(), received.lower()): + logger.warning( + 'Jupiter callback: invalid signature', + expected_prefix=expected[:8], + received_prefix=received[:8], + ) + return False + return True + except Exception as error: + logger.error('Jupiter callback verify error', error=error) + return False + + +# Singleton instance +jupiter_service = JupiterService() diff --git a/app/services/lava_service.py b/app/services/lava_service.py new file mode 100644 index 00000000..75f5505c --- /dev/null +++ b/app/services/lava_service.py @@ -0,0 +1,234 @@ +"""Сервис для работы с API Lava Business (gate.lava.ru).""" + +import hashlib +import hmac +import json +from typing import Any + +import aiohttp +import structlog + +from app.config import settings + + +logger = structlog.get_logger(__name__) + + +class LavaAPIError(Exception): + """Ошибка API Lava.""" + + def __init__(self, status_code: int, message: str, code: str | int | None = None) -> None: + self.status_code = status_code + self.message = message + self.api_code = code + super().__init__(f'Lava API error ({status_code}): {message}') + + +class LavaService: + """Клиент для Lava Business API (gate.lava.ru). + + Подпись запросов: HMAC-SHA256(json_body, secret_key) → hex. + Передаётся в заголовке ``Signature``. + Ключи `secret_key` (запросы) и `secret_key_2` (webhook) выдаются мерчанту в личном кабинете. + Каноническая строка для подписи — JSON в том же порядке, в котором отправляется в теле. + """ + + def __init__(self) -> None: + self._session: aiohttp.ClientSession | None = None + + @property + def base_url(self) -> str: + return (settings.LAVA_BASE_URL or 'https://gate.lava.ru').rstrip('/') + + @property + def shop_id(self) -> str: + return settings.LAVA_SHOP_ID or '' + + @property + def secret_key(self) -> str: + return settings.LAVA_SECRET_KEY or '' + + @property + def webhook_secret(self) -> str: + # secret_key_2 — для проверки подписи webhook'а + return settings.LAVA_WEBHOOK_SECRET or '' + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30), + ) + return self._session + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + @staticmethod + def _serialize(payload: dict[str, Any]) -> str: + """Сериализация JSON для подписи и тела запроса. + + Lava подписывает байт-в-байт ту же строку, что и отправляется в теле, поэтому + порядок ключей определяется порядком вставки в payload (Python ≥3.7 dict сохраняет + порядок). Используем компактный сепаратор и UTF-8 без экранирования юникода. + """ + return json.dumps(payload, separators=(',', ':'), ensure_ascii=False) + + def _hmac_hex(self, message: str | bytes, key: str | None = None) -> str: + secret = (key if key is not None else self.secret_key) or '' + msg_bytes = message if isinstance(message, (bytes, bytearray)) else message.encode('utf-8') + return hmac.new( + secret.encode('utf-8'), + msg=msg_bytes, + digestmod=hashlib.sha256, + ).hexdigest() + + def _build_headers(self, body: str) -> dict[str, str]: + return { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Signature': self._hmac_hex(body), + } + + async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + url = f'{self.base_url}/{path.lstrip("/")}' + body = self._serialize(payload) + try: + session = await self._get_session() + async with session.post(url, data=body, headers=self._build_headers(body)) as response: + try: + data = await response.json(content_type=None) + except Exception: + text = await response.text() + data = {'_raw': text} + if not isinstance(data, dict): + data = {'_raw': data} + if response.status >= 400: + error_msg = ( + data.get('error') + or (data.get('data') or {}).get('error') + or data.get('message') + or 'Lava API HTTP error' + ) + logger.warning( + 'Lava API HTTP error', + url=url, + status=response.status, + error_msg=str(error_msg), + code=data.get('code'), + ) + raise LavaAPIError(response.status, str(error_msg), data.get('code')) + return data + except aiohttp.ClientError as error: + logger.exception('Lava API connection error', url=url, error=error) + raise + + async def create_invoice( + self, + *, + amount_rubles: float, + order_id: str, + success_url: str | None = None, + fail_url: str | None = None, + hook_url: str | None = None, + expire_minutes: int | None = None, + comment: str | None = None, + custom_fields: str | None = None, + include_service: list[str] | None = None, + exclude_service: list[str] | None = None, + ) -> dict[str, Any]: + """Создаёт инвойс через POST /api/v2/invoice/create. + + Сумма передаётся в рублях с двумя знаками после запятой. + ``orderId`` — наш уникальный идентификатор платежа. + """ + # Порядок полей важен (этим же порядком сериализуется и подписывается) + payload: dict[str, Any] = { + 'sum': round(float(amount_rubles), 2), + 'orderId': str(order_id), + 'shopId': self.shop_id, + } + if hook_url: + payload['hookUrl'] = hook_url[:500] + if success_url: + payload['successUrl'] = success_url[:500] + if fail_url: + payload['failUrl'] = fail_url[:500] + if expire_minutes is not None: + # Lava лимит: 1..7200 минут (5 дней) + payload['expire'] = max(1, min(7200, int(expire_minutes))) + if comment: + payload['comment'] = comment[:255] + if custom_fields: + payload['customFields'] = custom_fields[:500] + if include_service: + payload['includeService'] = list(include_service) + if exclude_service: + payload['excludeService'] = list(exclude_service) + + logger.info('Lava API invoice/create', order_id=order_id, sum=payload['sum']) + data = await self._post('/api/v2/invoice/create', payload) + + # Lava возвращает {"status": "success", "data": {...}} или {"status": "error", "error": "..."} + if isinstance(data.get('status'), str) and data['status'].lower() == 'error': + raise LavaAPIError(200, str(data.get('error') or data.get('message') or 'unknown')) + + return data + + async def get_invoice_status( + self, + *, + order_id: str | None = None, + invoice_id: str | None = None, + ) -> dict[str, Any]: + """POST /api/v2/invoice/status — статус инвойса по orderId или invoiceId.""" + if not order_id and not invoice_id: + raise ValueError('Lava status: order_id or invoice_id required') + + payload: dict[str, Any] = {'shopId': self.shop_id} + if invoice_id: + payload['invoiceId'] = str(invoice_id) + if order_id: + payload['orderId'] = str(order_id) + + logger.info('Lava API invoice/status', order_id=order_id, invoice_id=invoice_id) + return await self._post('/api/v2/invoice/status', payload) + + async def get_services(self) -> dict[str, Any]: + """POST /api/v2/invoice/services — доступные методы оплаты для shopId.""" + payload: dict[str, Any] = {'shopId': self.shop_id} + return await self._post('/api/v2/invoice/services', payload) + + def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool: + """Верификация подписи webhook (заголовок ``Authorization``). + + Lava Business webhook подписан HMAC-SHA256 от raw JSON body ключом ``secret_key_2``. + """ + try: + if not received_signature: + logger.warning('Lava webhook: отсутствует Authorization header') + return False + if not self.webhook_secret: + logger.error('Lava webhook: LAVA_WEBHOOK_SECRET не настроен') + return False + + # HMAC берётся напрямую от raw bytes — без decode/encode round-trip, + # чтобы не терять байты при некорректной кодировке payload. + expected = self._hmac_hex(raw_body, key=self.webhook_secret) + received = received_signature.strip() + + if not hmac.compare_digest(expected.lower(), received.lower()): + logger.warning( + 'Lava webhook: invalid signature', + received_prefix=received[:8], + ) + return False + return True + except Exception as error: + logger.error('Lava webhook verify error', error=error) + return False + + +# Singleton instance +lava_service = LavaService() diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index a3476e01..acf63e44 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -1044,7 +1044,7 @@ class MonitoringService: # Day 1 reminder if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2: if not await notification_sent(db, user.id, subscription.id, 'expired_1d'): - success = await self._send_expired_day1_notification(user, subscription) + success = await self._send_expired_day1_notification(db, user, subscription) if success: await record_notification(db, user.id, subscription.id, 'expired_1d') sent_day1 += 1 @@ -1811,12 +1811,29 @@ class MonitoringService: ) return False - async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool: + async def _send_expired_day1_notification(self, db: AsyncSession, user: User, subscription: Subscription) -> bool: try: texts = get_texts(user.language) + tariff = getattr(subscription, 'tariff', None) tariff_label = '' - if settings.is_multi_tariff_enabled() and hasattr(subscription, 'tariff') and subscription.tariff: - tariff_label = f' «{subscription.tariff.name}»' + if settings.is_multi_tariff_enabled() and tariff: + tariff_label = f' «{tariff.name}»' + + renewal_period = (tariff.get_shortest_period() if tariff else None) or 30 + try: + from app.services.pricing_engine import pricing_engine + + pricing = await pricing_engine.calculate_renewal_price(db, subscription, renewal_period, user=user) + renewal_price_kopeks = pricing.final_total + except Exception as price_error: + logger.warning( + 'Не удалось рассчитать цену продления для уведомления expired_1d, используем PRICE_30_DAYS', + subscription_id=subscription.id, + user_id=user.id, + error=str(price_error), + ) + renewal_price_kopeks = settings.PRICE_30_DAYS + template = texts.get( 'SUBSCRIPTION_EXPIRED_1D', ( @@ -1826,7 +1843,7 @@ class MonitoringService: ) message = template.format( end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'), - price=settings.format_price(settings.PRICE_30_DAYS), + price=settings.format_price(renewal_price_kopeks), tariff_label=tariff_label, ) diff --git a/app/services/payment/antilopay.py b/app/services/payment/antilopay.py new file mode 100644 index 00000000..bd7bef63 --- /dev/null +++ b/app/services/payment/antilopay.py @@ -0,0 +1,531 @@ +"""Mixin для интеграции с Antilopay (lk.antilopay.com).""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta +from importlib import import_module +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import PaymentMethod, TransactionType +from app.services.antilopay_service import antilopay_service +from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info + + +# Маппинг статусов Antilopay -> internal +ANTILOPAY_STATUS_MAP: dict[str, tuple[str, bool]] = { + 'PENDING': ('pending', False), + 'SUCCESS': ('success', True), + 'FAIL': ('failed', False), + 'CANCEL': ('cancelled', False), + 'EXPIRED': ('expired', False), + 'CHARGEBACK': ('chargeback', False), + 'REVERSED': ('reversed', False), +} + + +class AntilopayPaymentMixin: + """Mixin для работы с платежами Antilopay.""" + + async def create_antilopay_payment( + self, + db: AsyncSession, + *, + user_id: int | None, + amount_kopeks: int, + description: str = 'Пополнение баланса', + email: str | None = None, + language: str = 'ru', + payment_method_type: str | None = None, + return_url: str | None = None, + ) -> dict[str, Any] | None: + """ + Создает платеж Antilopay. + + Returns: + Словарь с данными платежа или None при ошибке + """ + if not settings.is_antilopay_enabled(): + logger.error('Antilopay не настроен') + return None + + # Валидация лимитов + if amount_kopeks < settings.ANTILOPAY_MIN_AMOUNT_KOPEKS: + logger.warning( + 'Antilopay: сумма меньше минимальной', + amount_kopeks=amount_kopeks, + ANTILOPAY_MIN_AMOUNT_KOPEKS=settings.ANTILOPAY_MIN_AMOUNT_KOPEKS, + ) + return None + + if amount_kopeks > settings.ANTILOPAY_MAX_AMOUNT_KOPEKS: + logger.warning( + 'Antilopay: сумма больше максимальной', + amount_kopeks=amount_kopeks, + ANTILOPAY_MAX_AMOUNT_KOPEKS=settings.ANTILOPAY_MAX_AMOUNT_KOPEKS, + ) + return None + + # Получаем telegram_id пользователя для order_id + payment_module = import_module('app.services.payment_service') + if user_id is not None: + user = await payment_module.get_user_by_id(db, user_id) + tg_id = user.telegram_id if user else user_id + else: + user = None + tg_id = 'guest' + + # Генерируем уникальный order_id с telegram_id для удобного поиска + order_id = f'alp{tg_id}_{uuid.uuid4().hex[:6]}' + amount_rubles = amount_kopeks / 100 + currency = settings.ANTILOPAY_CURRENCY + + # Метаданные + metadata = { + 'user_id': user_id, + 'amount_kopeks': amount_kopeks, + 'description': description, + 'language': language, + 'type': 'balance_topup', + } + + try: + # Определяем prefer_methods по типу подметода + prefer_methods: list[str] | None = None + if payment_method_type == 'sbp': + prefer_methods = ['SBP'] + elif payment_method_type == 'card': + prefer_methods = ['CARD_RU'] + elif payment_method_type == 'sberpay': + prefer_methods = ['SBER_PAY'] + + # Формируем success/fail URL + result_url = return_url or settings.ANTILOPAY_RETURN_URL + + # merchant_extra — строка до 255 символов для callback + merchant_extra = order_id + + # Создаем платеж через API + api_result = await antilopay_service.create_payment( + amount_rubles=amount_rubles, + order_id=order_id, + product_name=settings.ANTILOPAY_PRODUCT_NAME, + product_type=settings.ANTILOPAY_PRODUCT_TYPE, + description=description, + customer_email=email, + prefer_methods=prefer_methods, + success_url=result_url, + fail_url=result_url, + merchant_extra=merchant_extra, + ) + + payment_id = api_result.get('payment_id') + payment_url = api_result.get('payment_url') + + logger.info( + 'Antilopay: получен ответ API', + order_id=order_id, + payment_id=payment_id, + payment_url=payment_url, + ) + + lifetime = settings.ANTILOPAY_PAYMENT_LIFETIME_MINUTES + expires_at = datetime.now(UTC) + timedelta(minutes=lifetime) + + # Сохраняем в БД + antilopay_crud = import_module('app.database.crud.antilopay') + local_payment = await antilopay_crud.create_antilopay_payment( + db=db, + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=payment_method_type, + antilopay_payment_id=payment_id, + expires_at=expires_at, + metadata_json=metadata, + ) + + logger.info( + 'Antilopay: создан платеж', + order_id=order_id, + user_id=user_id, + amount_rubles=amount_rubles, + currency=currency, + ) + + return { + 'order_id': order_id, + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_rubles, + 'currency': currency, + 'payment_url': payment_url, + 'payment_id': payment_id, + 'expires_at': expires_at.isoformat(), + 'local_payment_id': local_payment.id, + } + + except Exception as e: + logger.exception('Antilopay: ошибка создания платежа', error=e) + return None + + async def process_antilopay_callback( + self, + db: AsyncSession, + payload: dict[str, Any], + ) -> bool: + """ + Обрабатывает callback от Antilopay. + + Подпись проверяется в webserver/payments.py до вызова этого метода. + + Args: + db: Сессия БД + payload: JSON тело callback (signature проверена в webserver) + + Returns: + True если платеж успешно обработан + """ + try: + callback_type = payload.get('type') + if callback_type != 'payment': + logger.info('Antilopay callback: неизвестный тип', callback_type=callback_type) + return True # Не наш тип — не ошибка + + antilopay_payment_id = payload.get('payment_id') + antilopay_status = payload.get('status') + our_order_id = payload.get('order_id') + + if not our_order_id or not antilopay_status: + logger.warning('Antilopay callback: отсутствуют обязательные поля', payload=payload) + return False + + # Определяем is_paid по статусу + is_confirmed = antilopay_status == 'SUCCESS' + + # Ищем платеж по order_id + antilopay_crud = import_module('app.database.crud.antilopay') + payment = await antilopay_crud.get_antilopay_payment_by_order_id(db, our_order_id) + + if not payment: + logger.warning( + 'Antilopay callback: платеж не найден', + order_id=our_order_id, + ) + return False + + # Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race) + locked = await antilopay_crud.get_antilopay_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('Antilopay: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + + # Проверка дублирования (re-check from locked row) + if payment.is_paid: + logger.info('Antilopay callback: платеж уже обработан', order_id=payment.order_id) + return True + + # Маппинг статуса + status_info = ANTILOPAY_STATUS_MAP.get(antilopay_status, ('pending', False)) + internal_status, is_paid = status_info + + # Если статус SUCCESS, принудительно считаем оплаченным + if is_confirmed: + is_paid = True + internal_status = 'success' + + callback_payload = { + 'antilopay_payment_id': antilopay_payment_id, + 'status': antilopay_status, + 'amount': payload.get('amount'), + 'original_amount': payload.get('original_amount'), + 'fee': payload.get('fee'), + 'currency': payload.get('currency'), + 'pay_method': payload.get('pay_method'), + 'pay_data': payload.get('pay_data'), + 'customer': payload.get('customer'), + 'merchant_extra': payload.get('merchant_extra'), + } + + # Проверка суммы ДО обновления статуса + if is_paid: + original_amount = payload.get('original_amount') + if original_amount is not None: + # original_amount в РУБЛЯХ (float), конвертируем в копейки + received_kopeks = round(float(original_amount) * 100) + if abs(received_kopeks - payment.amount_kopeks) > 1: + logger.error( + 'Antilopay amount mismatch', + expected_kopeks=payment.amount_kopeks, + received_kopeks=received_kopeks, + order_id=payment.order_id, + ) + await antilopay_crud.update_antilopay_payment_status( + db=db, + payment=payment, + status='amount_mismatch', + is_paid=False, + callback_payload=callback_payload, + ) + return False + + # Финализируем платеж если оплачен — без промежуточного commit + if is_paid: + # Inline field assignments to keep FOR UPDATE lock intact + payment.status = internal_status + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.antilopay_payment_id = str(antilopay_payment_id) if antilopay_payment_id else None + payment.callback_payload = callback_payload + payment.updated_at = datetime.now(UTC) + await db.flush() + return await self._finalize_antilopay_payment(db, payment, trigger='webhook') + + # Для не-success статусов можно безопасно коммитить + payment = await antilopay_crud.update_antilopay_payment_status( + db=db, + payment=payment, + status=internal_status, + is_paid=False, + callback_payload=callback_payload, + ) + + return True + + except Exception as e: + logger.exception('Antilopay callback: ошибка обработки', error=e) + return False + + async def _finalize_antilopay_payment( + self, + db: AsyncSession, + payment: Any, + *, + trigger: str, + ) -> bool: + """Создаёт транзакцию, начисляет баланс и отправляет уведомления. + + FOR UPDATE lock must be acquired by the caller before invoking this method. + """ + payment_module = import_module('app.services.payment_service') + antilopay_crud = import_module('app.database.crud.antilopay') + + # FOR UPDATE lock already acquired by caller — just check idempotency + if payment.transaction_id: + logger.info( + 'Antilopay платеж уже связан с транзакцией', + order_id=payment.order_id, + transaction_id=payment.transaction_id, + trigger=trigger, + ) + return True + + # Read fresh metadata AFTER lock to avoid stale data + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + + # --- Guest purchase flow --- + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=metadata, + payment_amount_kopeks=payment.amount_kopeks, + provider_payment_id=payment.order_id, + provider_name='antilopay', + ) + if guest_result is not None: + return True + + # Ensure paid fields are set (idempotent — caller may have already set them) + if not payment.is_paid: + payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.updated_at = datetime.now(UTC) + + balance_already_credited = bool(metadata.get('balance_credited')) + + user = await payment_module.get_user_by_id(db, payment.user_id) + if not user: + logger.error('Пользователь не найден для Antilopay', user_id=payment.user_id) + return False + + # Загружаем промогруппы в асинхронном контексте + await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups']) + for user_promo_group in getattr(user, 'user_promo_groups', []): + await db.refresh(user_promo_group, attribute_names=['promo_group']) + + promo_group = user.get_primary_promo_group() + subscription = getattr(user, 'subscription', None) + referrer_info = format_referrer_info(user) + + transaction_external_id = payment.order_id + + # Проверяем дупликат транзакции + existing_transaction = None + if transaction_external_id: + existing_transaction = await payment_module.get_transaction_by_external_id( + db, + transaction_external_id, + PaymentMethod.ANTILOPAY, + ) + + display_name = settings.get_antilopay_display_name() + description = f'Пополнение через {display_name}' + + transaction = existing_transaction + created_transaction = False + + if not transaction: + transaction = await payment_module.create_transaction( + db, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + amount_kopeks=payment.amount_kopeks, + description=description, + payment_method=PaymentMethod.ANTILOPAY, + external_id=transaction_external_id, + is_completed=True, + created_at=getattr(payment, 'created_at', None), + commit=False, + ) + created_transaction = True + + await antilopay_crud.link_antilopay_payment_to_transaction(db, payment=payment, transaction_id=transaction.id) + + should_credit_balance = created_transaction or not balance_already_credited + + if not should_credit_balance: + logger.info('Antilopay платеж уже зачислил баланс ранее', order_id=payment.order_id) + return True + + # Lock user row to prevent concurrent balance race conditions + from app.database.crud.user import lock_user_for_update + + user = await lock_user_for_update(db, user) + + old_balance = user.balance_kopeks + was_first_topup = not user.has_made_first_topup + + user.balance_kopeks += payment.amount_kopeks + user.updated_at = datetime.now(UTC) + await db.commit() + await db.refresh(user) + + # Emit deferred side-effects after atomic commit + from app.database.crud.transaction import emit_transaction_side_effects + + await emit_transaction_side_effects( + db, + transaction, + amount_kopeks=payment.amount_kopeks, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + payment_method=PaymentMethod.ANTILOPAY, + external_id=transaction_external_id, + ) + + topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение' + + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup( + db, + user.id, + payment.amount_kopeks, + getattr(self, 'bot', None), + ) + except Exception as error: + logger.error('Ошибка обработки реферального пополнения Antilopay', error=error) + + if was_first_topup and not user.has_made_first_topup and not user.referred_by_id: + user.has_made_first_topup = True + await db.commit() + await db.refresh(user) + + if getattr(self, 'bot', None): + try: + from app.services.admin_notification_service import AdminNotificationService + + notification_service = AdminNotificationService(self.bot) + await notification_service.send_balance_topup_notification( + user, + transaction, + old_balance, + topup_status=topup_status, + referrer_info=referrer_info, + subscription=subscription, + promo_group=promo_group, + db=db, + ) + except Exception as error: + logger.error('Ошибка отправки админ уведомления Antilopay', error=error) + + if getattr(self, 'bot', None) and user.telegram_id: + try: + keyboard = await self.build_topup_success_keyboard(user) + await self.bot.send_message( + user.telegram_id, + ( + '\u2705 Пополнение успешно!\n\n' + f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n' + f'\U0001f4b3 Способ: {display_name}\n' + f'\U0001f194 Транзакция: {transaction.id}\n\n' + 'Баланс пополнен автоматически!' + ), + parse_mode='HTML', + reply_markup=keyboard, + ) + except Exception as error: + logger.error('Ошибка отправки уведомления пользователю Antilopay', error=error) + + try: + from app.services.payment.common import send_cart_notification_after_topup + + await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None)) + except Exception as error: + logger.error( + 'Ошибка при работе с сохраненной корзиной для пользователя', + user_id=payment.user_id, + error=error, + exc_info=True, + ) + + metadata['balance_change'] = { + 'old_balance': old_balance, + 'new_balance': user.balance_kopeks, + 'credited_at': datetime.now(UTC).isoformat(), + } + metadata['balance_credited'] = True + payment.metadata_json = metadata + await db.commit() + + logger.info( + 'Обработан Antilopay платеж', + order_id=payment.order_id, + user_id=payment.user_id, + trigger=trigger, + ) + + return True + + async def check_antilopay_payment_status( + self, + db: AsyncSession, + order_id: str, + ) -> dict[str, Any] | None: + """Проверяет статус платежа через API Antilopay.""" + try: + result = await antilopay_service.check_payment(order_id=order_id) + return result + except Exception as e: + logger.error('Antilopay: ошибка проверки статуса', order_id=order_id, error=e) + return None diff --git a/app/services/payment/donut.py b/app/services/payment/donut.py new file mode 100644 index 00000000..5a0671a0 --- /dev/null +++ b/app/services/payment/donut.py @@ -0,0 +1,500 @@ +"""Mixin для интеграции с Donut P2P (gw.donut.business).""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta +from importlib import import_module +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import PaymentMethod, TransactionType +from app.services.donut_service import donut_service +from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info + + +# Маппинг description при PayIn (Donut) <-> наш sub-method id +DONUT_METHOD_DESCRIPTIONS: dict[str | None, str] = { + None: 'CARD', + 'card': 'CARD', + 'sbp': 'SBP', + 'sbp_qr': 'SBP_QR', +} + + +# Маппинг статусов Donut -> internal +DONUT_STATUS_MAP: dict[str, tuple[str, bool]] = { + 'created': ('pending', False), + 'processing': ('pending', False), + 'success': ('success', True), + 'cancelled': ('cancelled', False), + 'error': ('error', False), +} + + +class DonutPaymentMixin: + """Mixin для работы с платежами Donut.""" + + async def create_donut_payment( + self, + db: AsyncSession, + *, + user_id: int | None, + amount_kopeks: int, + description: str = 'Пополнение баланса', + email: str | None = None, + language: str = 'ru', + payment_method_type: str | None = None, + return_url: str | None = None, + ) -> dict[str, Any] | None: + """Создаёт платёж Donut.""" + if not settings.is_donut_enabled(): + logger.error('Donut не настроен') + return None + + if amount_kopeks < settings.DONUT_MIN_AMOUNT_KOPEKS: + logger.warning( + 'Donut: сумма меньше минимальной', + amount_kopeks=amount_kopeks, + DONUT_MIN_AMOUNT_KOPEKS=settings.DONUT_MIN_AMOUNT_KOPEKS, + ) + return None + + if amount_kopeks > settings.DONUT_MAX_AMOUNT_KOPEKS: + logger.warning( + 'Donut: сумма больше максимальной', + amount_kopeks=amount_kopeks, + DONUT_MAX_AMOUNT_KOPEKS=settings.DONUT_MAX_AMOUNT_KOPEKS, + ) + return None + + payment_module = import_module('app.services.payment_service') + if user_id is not None: + user = await payment_module.get_user_by_id(db, user_id) + tg_id = user.telegram_id if user else user_id + else: + user = None + tg_id = 'guest' + + order_id = f'dnt{tg_id}_{uuid.uuid4().hex[:6]}' + amount_rubles = amount_kopeks / 100 + currency = settings.DONUT_CURRENCY + + method_key = (payment_method_type or '').lower() or None + method_description = DONUT_METHOD_DESCRIPTIONS.get(method_key, 'CARD') + + metadata = { + 'user_id': user_id, + 'amount_kopeks': amount_kopeks, + 'description': description, + 'language': language, + 'type': 'balance_topup', + 'payment_method_type': method_key, + 'donut_description': method_description, + } + + try: + callback_url = self._build_donut_callback_url() + customer_id = str(tg_id) if tg_id != 'guest' else f'guest-{order_id[-6:]}' + actual_return_url = return_url or settings.DONUT_RETURN_URL + + api_result = await donut_service.create_payment( + amount_rubles=amount_rubles, + order_id=order_id, + customer_id=customer_id, + method_description=method_description, + customer_email=email, + customer_phone=getattr(user, 'phone', None) if user else None, + callback_url=callback_url, + return_url=actual_return_url, + redirect=True, + ) + + transaction_id = api_result.get('transaction_id') + details = api_result.get('details') or {} + payment_url = api_result.get('redirect_url') or details.get('qrcode_url') or actual_return_url + + logger.info( + 'Donut: получен ответ API', + order_id=order_id, + transaction_id=transaction_id, + payment_url=payment_url, + method_description=method_description, + ) + + lifetime = settings.DONUT_PAYMENT_LIFETIME_MINUTES + expires_at = datetime.now(UTC) + timedelta(minutes=lifetime) + + donut_crud = import_module('app.database.crud.donut') + local_payment = await donut_crud.create_donut_payment( + db=db, + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=method_key, + donut_transaction_id=str(transaction_id) if transaction_id else None, + expires_at=expires_at, + metadata_json=metadata, + ) + + logger.info( + 'Donut: создан платеж', + order_id=order_id, + user_id=user_id, + amount_rubles=amount_rubles, + currency=currency, + ) + + return { + 'order_id': order_id, + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_rubles, + 'currency': currency, + 'payment_url': payment_url, + 'payment_id': str(transaction_id) if transaction_id else None, + 'expires_at': expires_at.isoformat(), + 'local_payment_id': local_payment.id, + } + + except Exception as e: + logger.exception('Donut: ошибка создания платежа', error=e) + return None + + @staticmethod + def _build_donut_callback_url() -> str | None: + """Собирает абсолютный callback URL для Donut.""" + webhook_path = settings.DONUT_WEBHOOK_PATH or '/donut-webhook' + base = ( + getattr(settings, 'WEBHOOK_URL', None) + or getattr(settings, 'WEB_API_BASE_URL', None) + or getattr(settings, 'CABINET_URL', None) + ) + if not base: + return None + return f'{base.rstrip("/")}{webhook_path if webhook_path.startswith("/") else "/" + webhook_path}' + + async def process_donut_callback( + self, + db: AsyncSession, + payload: dict[str, Any], + ) -> bool: + """Обрабатывает callback от Donut (подпись уже проверена в webserver).""" + try: + our_order_id = payload.get('order_id') + donut_transaction_id = payload.get('transaction_id') + status_obj = payload.get('status') or {} + donut_status = (status_obj.get('type') or '').strip().lower() + + if not our_order_id or not donut_status: + logger.warning('Donut callback: отсутствуют обязательные поля', payload=payload) + return False + + donut_crud = import_module('app.database.crud.donut') + payment = await donut_crud.get_donut_payment_by_order_id(db, our_order_id) + if not payment: + logger.warning('Donut callback: платеж не найден', order_id=our_order_id) + return False + + locked = await donut_crud.get_donut_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('Donut: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + + if payment.is_paid: + logger.info('Donut callback: платеж уже обработан', order_id=payment.order_id) + return True + + # Терминальные неуспешные статусы стики — провайдер не должен иметь возможность + # «починить» отклонённый/несовпавший платёж повторным callback'ом. + if payment.status in {'amount_mismatch', 'cancelled', 'error', 'expired'}: + logger.warning( + 'Donut callback: платёж в терминальном неуспешном статусе, игнорируется', + order_id=payment.order_id, + current_status=payment.status, + incoming_status=donut_status, + ) + return True + + internal_status, is_paid = DONUT_STATUS_MAP.get(donut_status, ('pending', False)) + + callback_payload = { + 'donut_transaction_id': donut_transaction_id, + 'status_type': donut_status, + 'amount': payload.get('amount'), + 'recalculated': payload.get('recalculated'), + 'timestamp': payload.get('timestamp'), + } + + if is_paid: + amount_obj = payload.get('amount') or {} + received_value = amount_obj.get('value') + if received_value is not None: + try: + received_kopeks = round(float(received_value) * 100) + except (TypeError, ValueError): + received_kopeks = None + if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1: + logger.error( + 'Donut amount mismatch', + expected_kopeks=payment.amount_kopeks, + received_kopeks=received_kopeks, + order_id=payment.order_id, + ) + await donut_crud.update_donut_payment_status( + db=db, + payment=payment, + status='amount_mismatch', + is_paid=False, + callback_payload=callback_payload, + ) + return False + + if is_paid: + payment.status = internal_status + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.donut_transaction_id = ( + str(donut_transaction_id) if donut_transaction_id else payment.donut_transaction_id + ) + payment.callback_payload = callback_payload + payment.updated_at = datetime.now(UTC) + await db.flush() + return await self._finalize_donut_payment(db, payment, trigger='webhook') + + payment = await donut_crud.update_donut_payment_status( + db=db, + payment=payment, + status=internal_status, + is_paid=False, + callback_payload=callback_payload, + ) + return True + + except Exception as e: + logger.exception('Donut callback: ошибка обработки', error=e) + return False + + async def _finalize_donut_payment( + self, + db: AsyncSession, + payment: Any, + *, + trigger: str, + ) -> bool: + """Создаёт транзакцию, начисляет баланс и отправляет уведомления. + + FOR UPDATE lock уже взят вызывающим. + """ + payment_module = import_module('app.services.payment_service') + donut_crud = import_module('app.database.crud.donut') + + if payment.transaction_id: + logger.info( + 'Donut платеж уже связан с транзакцией', + order_id=payment.order_id, + transaction_id=payment.transaction_id, + trigger=trigger, + ) + return True + + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=metadata, + payment_amount_kopeks=payment.amount_kopeks, + provider_payment_id=payment.order_id, + provider_name='donut', + ) + if guest_result is not None: + return True + + if not payment.is_paid: + payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.updated_at = datetime.now(UTC) + + balance_already_credited = bool(metadata.get('balance_credited')) + + user = await payment_module.get_user_by_id(db, payment.user_id) + if not user: + logger.error('Пользователь не найден для Donut', user_id=payment.user_id) + return False + + await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups']) + for user_promo_group in getattr(user, 'user_promo_groups', []): + await db.refresh(user_promo_group, attribute_names=['promo_group']) + + promo_group = user.get_primary_promo_group() + subscription = getattr(user, 'subscription', None) + referrer_info = format_referrer_info(user) + + transaction_external_id = payment.order_id + + existing_transaction = None + if transaction_external_id: + existing_transaction = await payment_module.get_transaction_by_external_id( + db, + transaction_external_id, + PaymentMethod.DONUT, + ) + + display_name = settings.get_donut_display_name() + description = f'Пополнение через {display_name}' + + transaction = existing_transaction + created_transaction = False + + if not transaction: + transaction = await payment_module.create_transaction( + db, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + amount_kopeks=payment.amount_kopeks, + description=description, + payment_method=PaymentMethod.DONUT, + external_id=transaction_external_id, + is_completed=True, + created_at=getattr(payment, 'created_at', None), + commit=False, + ) + created_transaction = True + + await donut_crud.link_donut_payment_to_transaction(db, payment=payment, transaction_id=transaction.id) + + should_credit_balance = created_transaction or not balance_already_credited + + if not should_credit_balance: + logger.info('Donut платеж уже зачислил баланс ранее', order_id=payment.order_id) + return True + + from app.database.crud.user import lock_user_for_update + + user = await lock_user_for_update(db, user) + + old_balance = user.balance_kopeks + was_first_topup = not user.has_made_first_topup + + user.balance_kopeks += payment.amount_kopeks + user.updated_at = datetime.now(UTC) + await db.commit() + await db.refresh(user) + + from app.database.crud.transaction import emit_transaction_side_effects + + await emit_transaction_side_effects( + db, + transaction, + amount_kopeks=payment.amount_kopeks, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + payment_method=PaymentMethod.DONUT, + external_id=transaction_external_id, + ) + + topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение' + + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup( + db, + user.id, + payment.amount_kopeks, + getattr(self, 'bot', None), + ) + except Exception as error: + logger.error('Ошибка обработки реферального пополнения Donut', error=error) + + if was_first_topup and not user.has_made_first_topup and not user.referred_by_id: + user.has_made_first_topup = True + await db.commit() + await db.refresh(user) + + if getattr(self, 'bot', None): + try: + from app.services.admin_notification_service import AdminNotificationService + + notification_service = AdminNotificationService(self.bot) + await notification_service.send_balance_topup_notification( + user, + transaction, + old_balance, + topup_status=topup_status, + referrer_info=referrer_info, + subscription=subscription, + promo_group=promo_group, + db=db, + ) + except Exception as error: + logger.error('Ошибка отправки админ уведомления Donut', error=error) + + if getattr(self, 'bot', None) and user.telegram_id: + try: + keyboard = await self.build_topup_success_keyboard(user) + await self.bot.send_message( + user.telegram_id, + ( + '✅ Пополнение успешно!\n\n' + f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n' + f'\U0001f4b3 Способ: {display_name}\n' + f'\U0001f194 Транзакция: {transaction.id}\n\n' + 'Баланс пополнен автоматически!' + ), + parse_mode='HTML', + reply_markup=keyboard, + ) + except Exception as error: + logger.error('Ошибка отправки уведомления пользователю Donut', error=error) + + try: + from app.services.payment.common import send_cart_notification_after_topup + + await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None)) + except Exception as error: + logger.error( + 'Ошибка при работе с сохраненной корзиной для пользователя', + user_id=payment.user_id, + error=error, + exc_info=True, + ) + + metadata['balance_change'] = { + 'old_balance': old_balance, + 'new_balance': user.balance_kopeks, + 'credited_at': datetime.now(UTC).isoformat(), + } + metadata['balance_credited'] = True + payment.metadata_json = metadata + await db.commit() + + logger.info( + 'Обработан Donut платеж', + order_id=payment.order_id, + user_id=payment.user_id, + trigger=trigger, + ) + + return True + + async def check_donut_payment_status( + self, + db: AsyncSession, + transaction_id: str, + ) -> dict[str, Any] | None: + """Запрос статуса платежа через API Donut.""" + try: + return await donut_service.check_payment(transaction_id=transaction_id) + except Exception as e: + logger.error('Donut: ошибка проверки статуса', transaction_id=transaction_id, error=e) + return None diff --git a/app/services/payment/etoplatezhi.py b/app/services/payment/etoplatezhi.py new file mode 100644 index 00000000..72bef0ef --- /dev/null +++ b/app/services/payment/etoplatezhi.py @@ -0,0 +1,518 @@ +"""Mixin для интеграции с Etoplatezhi (paymentpage.etoplatezhi.ru).""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta +from importlib import import_module +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import PaymentMethod, TransactionType +from app.services.etoplatezhi_service import etoplatezhi_service +from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info + + +# Маппинг статусов Etoplatezhi -> internal +ETOPLATEZHI_STATUS_MAP: dict[str, tuple[str, bool]] = { + 'success': ('success', True), + 'decline': ('declined', False), + 'error': ('error', False), + 'processing': ('pending', False), + 'awaiting 3ds result': ('pending', False), + 'awaiting redirect result': ('pending', False), + 'awaiting clarification': ('pending', False), + 'awaiting customer action': ('pending', False), + 'cancelled': ('cancelled', False), + 'refunded': ('refunded', False), + 'partially refunded': ('partially_refunded', False), + 'reversed': ('reversed', False), +} + + +class EtoplatezhiPaymentMixin: + """Mixin для работы с платежами Etoplatezhi.""" + + async def create_etoplatezhi_payment( + self, + db: AsyncSession, + *, + user_id: int | None, + amount_kopeks: int, + description: str = 'Пополнение баланса', + email: str | None = None, + language: str = 'ru', + payment_method_type: str | None = None, + return_url: str | None = None, + ) -> dict[str, Any] | None: + """ + Создает платеж Etoplatezhi. + + Returns: + Словарь с данными платежа или None при ошибке + """ + if not settings.is_etoplatezhi_enabled(): + logger.error('Etoplatezhi не настроен') + return None + + # Валидация лимитов + if amount_kopeks < settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS: + logger.warning( + 'Etoplatezhi: сумма меньше минимальной', + amount_kopeks=amount_kopeks, + ETOPLATEZHI_MIN_AMOUNT_KOPEKS=settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS, + ) + return None + + if amount_kopeks > settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS: + logger.warning( + 'Etoplatezhi: сумма больше максимальной', + amount_kopeks=amount_kopeks, + ETOPLATEZHI_MAX_AMOUNT_KOPEKS=settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS, + ) + return None + + # Получаем telegram_id пользователя для order_id + payment_module = import_module('app.services.payment_service') + if user_id is not None: + user = await payment_module.get_user_by_id(db, user_id) + tg_id = user.telegram_id if user else user_id + else: + user = None + tg_id = 'guest' + + # Генерируем уникальный order_id с telegram_id для удобного поиска + order_id = f'etp{tg_id}_{uuid.uuid4().hex[:6]}' + amount_rubles = amount_kopeks / 100 + currency = settings.ETOPLATEZHI_CURRENCY + + # Метаданные + metadata = { + 'user_id': user_id, + 'amount_kopeks': amount_kopeks, + 'description': description, + 'language': language, + 'type': 'balance_topup', + } + + try: + # Формируем webhook URL + webhook_url = None + if settings.WEBHOOK_URL: + webhook_url = f'{settings.WEBHOOK_URL.rstrip("/")}{settings.ETOPLATEZHI_WEBHOOK_PATH}' + + lifetime = settings.ETOPLATEZHI_PAYMENT_LIFETIME_MINUTES + + # Определяем force_payment_method по типу подметода + force_method = None + if payment_method_type == 'sbp': + force_method = 'sbp' + elif payment_method_type == 'card': + force_method = 'card' + + # Строим URL для редиректа на платёжную страницу + payment_url = etoplatezhi_service.build_payment_url( + project_id=settings.ETOPLATEZHI_PROJECT_ID or 0, + payment_id=order_id, + payment_amount=amount_kopeks, + payment_currency=currency, + customer_id=str(tg_id), + description=description, + callback_url=webhook_url, + success_url=return_url or settings.ETOPLATEZHI_RETURN_URL, + fail_url=return_url or settings.ETOPLATEZHI_RETURN_URL, + force_payment_method=force_method, + customer_email=email, + language_code=language, + ) + + logger.info( + 'Etoplatezhi: сформирован URL платежа', + order_id=order_id, + payment_url=payment_url, + ) + + expires_at = datetime.now(UTC) + timedelta(minutes=lifetime) + + # Сохраняем в БД + etoplatezhi_crud = import_module('app.database.crud.etoplatezhi') + local_payment = await etoplatezhi_crud.create_etoplatezhi_payment( + db=db, + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=payment_method_type, + etoplatezhi_payment_id=None, + expires_at=expires_at, + metadata_json=metadata, + ) + + logger.info( + 'Etoplatezhi: создан платеж', + order_id=order_id, + user_id=user_id, + amount_rubles=amount_rubles, + currency=currency, + ) + + return { + 'order_id': order_id, + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_rubles, + 'currency': currency, + 'payment_url': payment_url, + 'expires_at': expires_at.isoformat(), + 'local_payment_id': local_payment.id, + } + + except Exception as e: + logger.exception('Etoplatezhi: ошибка создания платежа', error=e) + return None + + async def process_etoplatezhi_callback( + self, + db: AsyncSession, + payload: dict[str, Any], + ) -> bool: + """ + Обрабатывает callback от Etoplatezhi. + + Подпись проверяется в webserver/payments.py до вызова этого метода. + + Args: + db: Сессия БД + payload: JSON тело callback (signature проверена в webserver) + + Returns: + True если платеж успешно обработан + """ + try: + # Etoplatezhi callback structure: + # {project_id, payment: {id, status, sum: {amount, currency}}, customer: {id}, signature} + payment_data = payload.get('payment', {}) + etoplatezhi_payment_id = payment_data.get('id') + etoplatezhi_status = payment_data.get('status') + + # payment.id в callback — это наш payment_id (order_id) + our_payment_id = str(etoplatezhi_payment_id) if etoplatezhi_payment_id else None + + if not our_payment_id or not etoplatezhi_status: + logger.warning('Etoplatezhi callback: отсутствуют обязательные поля', payload=payload) + return False + + # Определяем is_paid по статусу + is_confirmed = etoplatezhi_status == 'success' + + # Ищем платеж по order_id (наш payment_id = order_id) + etoplatezhi_crud = import_module('app.database.crud.etoplatezhi') + payment = await etoplatezhi_crud.get_etoplatezhi_payment_by_order_id(db, our_payment_id) + + if not payment: + logger.warning( + 'Etoplatezhi callback: платеж не найден', + payment_id=our_payment_id, + ) + return False + + # Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race) + locked = await etoplatezhi_crud.get_etoplatezhi_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('Etoplatezhi: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + + # Проверка дублирования (re-check from locked row) + if payment.is_paid: + logger.info('Etoplatezhi callback: платеж уже обработан', order_id=payment.order_id) + return True + + # Маппинг статуса + status_info = ETOPLATEZHI_STATUS_MAP.get(etoplatezhi_status, ('pending', False)) + internal_status, is_paid = status_info + + # Если статус success, принудительно считаем оплаченным + if is_confirmed: + is_paid = True + internal_status = 'success' + + # Извлекаем сумму из callback: payment.sum.amount (в минорных единицах) + sum_data = payment_data.get('sum', {}) + + callback_payload = { + 'etoplatezhi_payment_id': etoplatezhi_payment_id, + 'status': etoplatezhi_status, + 'sum': sum_data, + 'customer': payload.get('customer'), + 'project_id': payload.get('project_id'), + } + + # Проверка суммы ДО обновления статуса + if is_paid: + amount_value = sum_data.get('amount') + if amount_value is not None: + # amount в минорных единицах (копейках) + received_kopeks = int(amount_value) + if abs(received_kopeks - payment.amount_kopeks) > 1: + logger.error( + 'Etoplatezhi amount mismatch', + expected_kopeks=payment.amount_kopeks, + received_kopeks=received_kopeks, + order_id=payment.order_id, + ) + await etoplatezhi_crud.update_etoplatezhi_payment_status( + db=db, + payment=payment, + status='amount_mismatch', + is_paid=False, + callback_payload=callback_payload, + ) + return False + + # Финализируем платеж если оплачен — без промежуточного commit + if is_paid: + # Inline field assignments to keep FOR UPDATE lock intact + payment.status = internal_status + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.etoplatezhi_payment_id = str(etoplatezhi_payment_id) if etoplatezhi_payment_id else None + payment.callback_payload = callback_payload + payment.updated_at = datetime.now(UTC) + await db.flush() + return await self._finalize_etoplatezhi_payment(db, payment, trigger='webhook') + + # Для не-success статусов можно безопасно коммитить + payment = await etoplatezhi_crud.update_etoplatezhi_payment_status( + db=db, + payment=payment, + status=internal_status, + is_paid=False, + callback_payload=callback_payload, + ) + + return True + + except Exception as e: + logger.exception('Etoplatezhi callback: ошибка обработки', error=e) + return False + + async def _finalize_etoplatezhi_payment( + self, + db: AsyncSession, + payment: Any, + *, + trigger: str, + ) -> bool: + """Создаёт транзакцию, начисляет баланс и отправляет уведомления. + + FOR UPDATE lock must be acquired by the caller before invoking this method. + """ + payment_module = import_module('app.services.payment_service') + etoplatezhi_crud = import_module('app.database.crud.etoplatezhi') + + # FOR UPDATE lock already acquired by caller — just check idempotency + if payment.transaction_id: + logger.info( + 'Etoplatezhi платеж уже связан с транзакцией', + order_id=payment.order_id, + transaction_id=payment.transaction_id, + trigger=trigger, + ) + return True + + # Read fresh metadata AFTER lock to avoid stale data + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + + # --- Guest purchase flow --- + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=metadata, + payment_amount_kopeks=payment.amount_kopeks, + provider_payment_id=payment.order_id, + provider_name='etoplatezhi', + ) + if guest_result is not None: + return True + + # Ensure paid fields are set (idempotent — caller may have already set them) + if not payment.is_paid: + payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.updated_at = datetime.now(UTC) + + balance_already_credited = bool(metadata.get('balance_credited')) + + user = await payment_module.get_user_by_id(db, payment.user_id) + if not user: + logger.error('Пользователь не найден для Etoplatezhi', user_id=payment.user_id) + return False + + # Загружаем промогруппы в асинхронном контексте + await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups']) + for user_promo_group in getattr(user, 'user_promo_groups', []): + await db.refresh(user_promo_group, attribute_names=['promo_group']) + + promo_group = user.get_primary_promo_group() + subscription = getattr(user, 'subscription', None) + referrer_info = format_referrer_info(user) + + transaction_external_id = payment.order_id + + # Проверяем дупликат транзакции + existing_transaction = None + if transaction_external_id: + existing_transaction = await payment_module.get_transaction_by_external_id( + db, + transaction_external_id, + PaymentMethod.ETOPLATEZHI, + ) + + display_name = settings.get_etoplatezhi_display_name() + description = f'Пополнение через {display_name}' + + transaction = existing_transaction + created_transaction = False + + if not transaction: + transaction = await payment_module.create_transaction( + db, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + amount_kopeks=payment.amount_kopeks, + description=description, + payment_method=PaymentMethod.ETOPLATEZHI, + external_id=transaction_external_id, + is_completed=True, + created_at=getattr(payment, 'created_at', None), + commit=False, + ) + created_transaction = True + + await etoplatezhi_crud.link_etoplatezhi_payment_to_transaction( + db, payment=payment, transaction_id=transaction.id + ) + + should_credit_balance = created_transaction or not balance_already_credited + + if not should_credit_balance: + logger.info('Etoplatezhi платеж уже зачислил баланс ранее', order_id=payment.order_id) + return True + + # Lock user row to prevent concurrent balance race conditions + from app.database.crud.user import lock_user_for_update + + user = await lock_user_for_update(db, user) + + old_balance = user.balance_kopeks + was_first_topup = not user.has_made_first_topup + + user.balance_kopeks += payment.amount_kopeks + user.updated_at = datetime.now(UTC) + await db.commit() + await db.refresh(user) + + # Emit deferred side-effects after atomic commit + from app.database.crud.transaction import emit_transaction_side_effects + + await emit_transaction_side_effects( + db, + transaction, + amount_kopeks=payment.amount_kopeks, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + payment_method=PaymentMethod.ETOPLATEZHI, + external_id=transaction_external_id, + ) + + topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение' + + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup( + db, + user.id, + payment.amount_kopeks, + getattr(self, 'bot', None), + ) + except Exception as error: + logger.error('Ошибка обработки реферального пополнения Etoplatezhi', error=error) + + if was_first_topup and not user.has_made_first_topup and not user.referred_by_id: + user.has_made_first_topup = True + await db.commit() + await db.refresh(user) + + if getattr(self, 'bot', None): + try: + from app.services.admin_notification_service import AdminNotificationService + + notification_service = AdminNotificationService(self.bot) + await notification_service.send_balance_topup_notification( + user, + transaction, + old_balance, + topup_status=topup_status, + referrer_info=referrer_info, + subscription=subscription, + promo_group=promo_group, + db=db, + ) + except Exception as error: + logger.error('Ошибка отправки админ уведомления Etoplatezhi', error=error) + + if getattr(self, 'bot', None) and user.telegram_id: + try: + keyboard = await self.build_topup_success_keyboard(user) + await self.bot.send_message( + user.telegram_id, + ( + '\u2705 Пополнение успешно!\n\n' + f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n' + f'\U0001f4b3 Способ: {display_name}\n' + f'\U0001f194 Транзакция: {transaction.id}\n\n' + 'Баланс пополнен автоматически!' + ), + parse_mode='HTML', + reply_markup=keyboard, + ) + except Exception as error: + logger.error('Ошибка отправки уведомления пользователю Etoplatezhi', error=error) + + try: + from app.services.payment.common import send_cart_notification_after_topup + + await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None)) + except Exception as error: + logger.error( + 'Ошибка при работе с сохраненной корзиной для пользователя', + user_id=payment.user_id, + error=error, + exc_info=True, + ) + + metadata['balance_change'] = { + 'old_balance': old_balance, + 'new_balance': user.balance_kopeks, + 'credited_at': datetime.now(UTC).isoformat(), + } + metadata['balance_credited'] = True + payment.metadata_json = metadata + await db.commit() + + logger.info( + 'Обработан Etoplatezhi платеж', + order_id=payment.order_id, + user_id=payment.user_id, + trigger=trigger, + ) + + return True diff --git a/app/services/payment/jupiter.py b/app/services/payment/jupiter.py new file mode 100644 index 00000000..19fcead7 --- /dev/null +++ b/app/services/payment/jupiter.py @@ -0,0 +1,494 @@ +"""Mixin для интеграции с Jupiter (FPGate P2P v2.1, app.juppiter.tech).""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta +from importlib import import_module +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import PaymentMethod, TransactionType +from app.services.jupiter_service import jupiter_service +from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info + + +# Маппинг статусов Jupiter -> internal +JUPITER_STATUS_MAP: dict[str, tuple[str, bool]] = { + 'success': ('success', True), + 'processing': ('pending', False), + 'cancelled': ('cancelled', False), + 'declined': ('declined', False), + 'error': ('error', False), +} + + +class JupiterPaymentMixin: + """Mixin для работы с платежами Jupiter.""" + + async def create_jupiter_payment( + self, + db: AsyncSession, + *, + user_id: int | None, + amount_kopeks: int, + description: str = 'Пополнение баланса', + email: str | None = None, + language: str = 'ru', + payment_method_type: str | None = None, + return_url: str | None = None, + ) -> dict[str, Any] | None: + """Создаёт платёж Jupiter. + + Параметр ``return_url`` принимается для совместимости сигнатуры с другими провайдерами + (cabinet/routes/balance.py одинаково вызывает все ``create_*_payment``), но фактически + не используется: спецификация Jupiter v2.1 имеет поле ``redirect`` зарезервированное + для будущего использования и не поддерживает return-URL семантику. Пользователь + видит QR-код СБП и подтверждает оплату в банковском приложении. + """ + if not settings.is_jupiter_enabled(): + logger.error('Jupiter не настроен') + return None + + if amount_kopeks < settings.JUPITER_MIN_AMOUNT_KOPEKS: + logger.warning( + 'Jupiter: сумма меньше минимальной', + amount_kopeks=amount_kopeks, + JUPITER_MIN_AMOUNT_KOPEKS=settings.JUPITER_MIN_AMOUNT_KOPEKS, + ) + return None + + if amount_kopeks > settings.JUPITER_MAX_AMOUNT_KOPEKS: + logger.warning( + 'Jupiter: сумма больше максимальной', + amount_kopeks=amount_kopeks, + JUPITER_MAX_AMOUNT_KOPEKS=settings.JUPITER_MAX_AMOUNT_KOPEKS, + ) + return None + + payment_module = import_module('app.services.payment_service') + if user_id is not None: + user = await payment_module.get_user_by_id(db, user_id) + tg_id = user.telegram_id if user else user_id + else: + user = None + tg_id = 'guest' + + order_id = f'jup{tg_id}_{uuid.uuid4().hex[:6]}' + amount_rubles = amount_kopeks / 100 + currency = settings.JUPITER_CURRENCY + + metadata = { + 'user_id': user_id, + 'amount_kopeks': amount_kopeks, + 'description': description, + 'language': language, + 'type': 'balance_topup', + 'payment_method_type': payment_method_type, + } + + try: + callback_url = self._build_jupiter_callback_url() + customer_id = str(tg_id) if tg_id != 'guest' else f'guest-{order_id[-6:]}' + customer_name = ( + getattr(user, 'first_name', None) or getattr(user, 'username', None) or settings.JUPITER_FALLBACK_NAME + ) + + api_result = await jupiter_service.create_payment( + amount_rubles=amount_rubles, + order_id=order_id, + customer_id=customer_id, + customer_email=email, + customer_name=customer_name, + callback_url=callback_url, + description=description[:255] if description else None, + ) + + transaction_id = api_result.get('transaction_id') + details = api_result.get('details') or {} + payment_url = details.get('qrcode_url') or details.get('url') + + logger.info( + 'Jupiter: получен ответ API', + order_id=order_id, + transaction_id=transaction_id, + payment_url=payment_url, + ) + + lifetime = settings.JUPITER_PAYMENT_LIFETIME_MINUTES + expires_at = datetime.now(UTC) + timedelta(minutes=lifetime) + + jupiter_crud = import_module('app.database.crud.jupiter') + local_payment = await jupiter_crud.create_jupiter_payment( + db=db, + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=payment_method_type, + jupiter_transaction_id=str(transaction_id) if transaction_id else None, + expires_at=expires_at, + metadata_json=metadata, + ) + + logger.info( + 'Jupiter: создан платеж', + order_id=order_id, + user_id=user_id, + amount_rubles=amount_rubles, + currency=currency, + ) + + return { + 'order_id': order_id, + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_rubles, + 'currency': currency, + 'payment_url': payment_url, + 'payment_id': str(transaction_id) if transaction_id else None, + 'expires_at': expires_at.isoformat(), + 'local_payment_id': local_payment.id, + } + + except Exception as e: + logger.exception('Jupiter: ошибка создания платежа', error=e) + return None + + @staticmethod + def _build_jupiter_callback_url() -> str | None: + """Собирает абсолютный callback URL для Jupiter.""" + webhook_path = settings.JUPITER_WEBHOOK_PATH or '/jupiter-webhook' + base = ( + getattr(settings, 'WEBHOOK_URL', None) + or getattr(settings, 'WEB_API_BASE_URL', None) + or getattr(settings, 'CABINET_URL', None) + ) + if not base: + return None + return f'{base.rstrip("/")}{webhook_path if webhook_path.startswith("/") else "/" + webhook_path}' + + async def process_jupiter_callback( + self, + db: AsyncSession, + payload: dict[str, Any], + ) -> bool: + """Обрабатывает callback от Jupiter (подпись уже проверена в webserver).""" + try: + our_order_id = payload.get('order_id') + jupiter_transaction_id = payload.get('transaction_id') + status_obj = payload.get('status') or {} + jupiter_status = (status_obj.get('type') or '').strip().lower() + + if not our_order_id or not jupiter_status: + logger.warning('Jupiter callback: отсутствуют обязательные поля', payload=payload) + return False + + jupiter_crud = import_module('app.database.crud.jupiter') + payment = await jupiter_crud.get_jupiter_payment_by_order_id(db, our_order_id) + if not payment: + logger.warning('Jupiter callback: платеж не найден', order_id=our_order_id) + return False + + locked = await jupiter_crud.get_jupiter_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('Jupiter: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + + if payment.is_paid: + logger.info('Jupiter callback: платеж уже обработан', order_id=payment.order_id) + return True + + # Терминальные неуспешные статусы стики — провайдер не должен иметь возможность + # «починить» отклонённый/несовпавший платёж повторным callback'ом. + if payment.status in {'amount_mismatch', 'cancelled', 'declined', 'error', 'expired'}: + logger.warning( + 'Jupiter callback: платёж в терминальном неуспешном статусе, игнорируется', + order_id=payment.order_id, + current_status=payment.status, + incoming_status=jupiter_status, + ) + return True + + internal_status, is_paid = JUPITER_STATUS_MAP.get(jupiter_status, ('pending', False)) + + callback_payload = { + 'jupiter_transaction_id': jupiter_transaction_id, + 'status_type': jupiter_status, + 'amount': payload.get('amount'), + 'recalculated': payload.get('recalculated'), + 'timestamp': payload.get('timestamp'), + } + + # Сверяем сумму ДО обновления статуса + if is_paid: + amount_obj = payload.get('amount') or {} + received_value = amount_obj.get('value') + if received_value is not None: + try: + received_kopeks = round(float(received_value) * 100) + except (TypeError, ValueError): + received_kopeks = None + if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1: + logger.error( + 'Jupiter amount mismatch', + expected_kopeks=payment.amount_kopeks, + received_kopeks=received_kopeks, + order_id=payment.order_id, + ) + await jupiter_crud.update_jupiter_payment_status( + db=db, + payment=payment, + status='amount_mismatch', + is_paid=False, + callback_payload=callback_payload, + ) + return False + + if is_paid: + payment.status = internal_status + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.jupiter_transaction_id = ( + str(jupiter_transaction_id) if jupiter_transaction_id else payment.jupiter_transaction_id + ) + payment.callback_payload = callback_payload + payment.updated_at = datetime.now(UTC) + await db.flush() + return await self._finalize_jupiter_payment(db, payment, trigger='webhook') + + payment = await jupiter_crud.update_jupiter_payment_status( + db=db, + payment=payment, + status=internal_status, + is_paid=False, + callback_payload=callback_payload, + ) + return True + + except Exception as e: + logger.exception('Jupiter callback: ошибка обработки', error=e) + return False + + async def _finalize_jupiter_payment( + self, + db: AsyncSession, + payment: Any, + *, + trigger: str, + ) -> bool: + """Создаёт транзакцию, начисляет баланс и отправляет уведомления. + + FOR UPDATE lock уже взят вызывающим. + """ + payment_module = import_module('app.services.payment_service') + jupiter_crud = import_module('app.database.crud.jupiter') + + if payment.transaction_id: + logger.info( + 'Jupiter платеж уже связан с транзакцией', + order_id=payment.order_id, + transaction_id=payment.transaction_id, + trigger=trigger, + ) + return True + + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=metadata, + payment_amount_kopeks=payment.amount_kopeks, + provider_payment_id=payment.order_id, + provider_name='jupiter', + ) + if guest_result is not None: + return True + + if not payment.is_paid: + payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.updated_at = datetime.now(UTC) + + balance_already_credited = bool(metadata.get('balance_credited')) + + user = await payment_module.get_user_by_id(db, payment.user_id) + if not user: + logger.error('Пользователь не найден для Jupiter', user_id=payment.user_id) + return False + + await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups']) + for user_promo_group in getattr(user, 'user_promo_groups', []): + await db.refresh(user_promo_group, attribute_names=['promo_group']) + + promo_group = user.get_primary_promo_group() + subscription = getattr(user, 'subscription', None) + referrer_info = format_referrer_info(user) + + transaction_external_id = payment.order_id + + existing_transaction = None + if transaction_external_id: + existing_transaction = await payment_module.get_transaction_by_external_id( + db, + transaction_external_id, + PaymentMethod.JUPITER, + ) + + display_name = settings.get_jupiter_display_name() + description = f'Пополнение через {display_name}' + + transaction = existing_transaction + created_transaction = False + + if not transaction: + transaction = await payment_module.create_transaction( + db, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + amount_kopeks=payment.amount_kopeks, + description=description, + payment_method=PaymentMethod.JUPITER, + external_id=transaction_external_id, + is_completed=True, + created_at=getattr(payment, 'created_at', None), + commit=False, + ) + created_transaction = True + + await jupiter_crud.link_jupiter_payment_to_transaction(db, payment=payment, transaction_id=transaction.id) + + should_credit_balance = created_transaction or not balance_already_credited + + if not should_credit_balance: + logger.info('Jupiter платеж уже зачислил баланс ранее', order_id=payment.order_id) + return True + + from app.database.crud.user import lock_user_for_update + + user = await lock_user_for_update(db, user) + + old_balance = user.balance_kopeks + was_first_topup = not user.has_made_first_topup + + user.balance_kopeks += payment.amount_kopeks + user.updated_at = datetime.now(UTC) + await db.commit() + await db.refresh(user) + + from app.database.crud.transaction import emit_transaction_side_effects + + await emit_transaction_side_effects( + db, + transaction, + amount_kopeks=payment.amount_kopeks, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + payment_method=PaymentMethod.JUPITER, + external_id=transaction_external_id, + ) + + topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение' + + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup( + db, + user.id, + payment.amount_kopeks, + getattr(self, 'bot', None), + ) + except Exception as error: + logger.error('Ошибка обработки реферального пополнения Jupiter', error=error) + + if was_first_topup and not user.has_made_first_topup and not user.referred_by_id: + user.has_made_first_topup = True + await db.commit() + await db.refresh(user) + + if getattr(self, 'bot', None): + try: + from app.services.admin_notification_service import AdminNotificationService + + notification_service = AdminNotificationService(self.bot) + await notification_service.send_balance_topup_notification( + user, + transaction, + old_balance, + topup_status=topup_status, + referrer_info=referrer_info, + subscription=subscription, + promo_group=promo_group, + db=db, + ) + except Exception as error: + logger.error('Ошибка отправки админ уведомления Jupiter', error=error) + + if getattr(self, 'bot', None) and user.telegram_id: + try: + keyboard = await self.build_topup_success_keyboard(user) + await self.bot.send_message( + user.telegram_id, + ( + '✅ Пополнение успешно!\n\n' + f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n' + f'\U0001f4b3 Способ: {display_name}\n' + f'\U0001f194 Транзакция: {transaction.id}\n\n' + 'Баланс пополнен автоматически!' + ), + parse_mode='HTML', + reply_markup=keyboard, + ) + except Exception as error: + logger.error('Ошибка отправки уведомления пользователю Jupiter', error=error) + + try: + from app.services.payment.common import send_cart_notification_after_topup + + await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None)) + except Exception as error: + logger.error( + 'Ошибка при работе с сохраненной корзиной для пользователя', + user_id=payment.user_id, + error=error, + exc_info=True, + ) + + metadata['balance_change'] = { + 'old_balance': old_balance, + 'new_balance': user.balance_kopeks, + 'credited_at': datetime.now(UTC).isoformat(), + } + metadata['balance_credited'] = True + payment.metadata_json = metadata + await db.commit() + + logger.info( + 'Обработан Jupiter платеж', + order_id=payment.order_id, + user_id=payment.user_id, + trigger=trigger, + ) + + return True + + async def check_jupiter_payment_status( + self, + db: AsyncSession, + transaction_id: str, + ) -> dict[str, Any] | None: + """Запрос статуса платежа через API Jupiter.""" + try: + return await jupiter_service.check_payment(transaction_id=transaction_id) + except Exception as e: + logger.error('Jupiter: ошибка проверки статуса', transaction_id=transaction_id, error=e) + return None diff --git a/app/services/payment/lava.py b/app/services/payment/lava.py new file mode 100644 index 00000000..da05a5fb --- /dev/null +++ b/app/services/payment/lava.py @@ -0,0 +1,590 @@ +"""Mixin для интеграции с Lava Business (gate.lava.ru).""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta +from importlib import import_module +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import PaymentMethod, TransactionType +from app.services.lava_service import lava_service +from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info + + +# Маппинг sub-method -> includeService для фильтрации методов на странице оплаты Lava +LAVA_INCLUDE_SERVICE_MAP: dict[str | None, list[str] | None] = { + None: None, + 'card': ['card'], + 'sbp': ['sbp'], +} + + +# Маппинг статусов Lava -> internal +LAVA_STATUS_MAP: dict[str, tuple[str, bool]] = { + 'created': ('pending', False), + 'pending': ('pending', False), + 'processing': ('pending', False), # на случай промежуточного статуса + 'success': ('success', True), + 'cancel': ('cancelled', False), + 'cancelled': ('cancelled', False), + 'expired': ('expired', False), + 'error': ('error', False), + 'failed': ('failed', False), +} + + +class LavaPaymentMixin: + """Mixin для работы с платежами Lava Business.""" + + async def create_lava_payment( + self, + db: AsyncSession, + *, + user_id: int | None, + amount_kopeks: int, + description: str = 'Пополнение баланса', + email: str | None = None, + language: str = 'ru', + payment_method_type: str | None = None, + return_url: str | None = None, + ) -> dict[str, Any] | None: + """Создаёт инвойс Lava.""" + if not settings.is_lava_enabled(): + logger.error('Lava не настроен') + return None + + if amount_kopeks < settings.LAVA_MIN_AMOUNT_KOPEKS: + logger.warning( + 'Lava: сумма меньше минимальной', + amount_kopeks=amount_kopeks, + LAVA_MIN_AMOUNT_KOPEKS=settings.LAVA_MIN_AMOUNT_KOPEKS, + ) + return None + + if amount_kopeks > settings.LAVA_MAX_AMOUNT_KOPEKS: + logger.warning( + 'Lava: сумма больше максимальной', + amount_kopeks=amount_kopeks, + LAVA_MAX_AMOUNT_KOPEKS=settings.LAVA_MAX_AMOUNT_KOPEKS, + ) + return None + + payment_module = import_module('app.services.payment_service') + if user_id is not None: + user = await payment_module.get_user_by_id(db, user_id) + tg_id = user.telegram_id if user else user_id + else: + user = None + tg_id = 'guest' + + # 32 hex char (128 бит) суффикс — order_id уникален даже при публичном tg_id + order_id = f'lava{tg_id}_{uuid.uuid4().hex}' + amount_rubles = amount_kopeks / 100 + currency = settings.LAVA_CURRENCY + + method_key = (payment_method_type or '').lower() or None + include_service = LAVA_INCLUDE_SERVICE_MAP.get(method_key) + + metadata = { + 'user_id': user_id, + 'amount_kopeks': amount_kopeks, + 'description': description, + 'language': language, + 'type': 'balance_topup', + 'payment_method_type': method_key, + 'email': email, + } + + try: + hook_url = self._build_lava_hook_url() + if not hook_url: + logger.warning( + 'Lava: hook_url не сконфигурирован — ' + 'платёж создаётся, но автоматическое подтверждение через webhook невозможно. ' + 'Установите WEBHOOK_URL / WEB_API_BASE_URL / CABINET_URL.' + ) + actual_return_url = return_url or settings.LAVA_RETURN_URL + + api_result = await lava_service.create_invoice( + amount_rubles=amount_rubles, + order_id=order_id, + hook_url=hook_url, + success_url=actual_return_url, + fail_url=actual_return_url, + expire_minutes=settings.LAVA_PAYMENT_LIFETIME_MINUTES, + comment=(description or '')[:255] or None, + custom_fields=str(user_id) if user_id is not None else None, + include_service=include_service, + ) + + data = (api_result.get('data') or api_result) if isinstance(api_result, dict) else {} + lava_invoice_id = data.get('id') or data.get('invoice_id') + payment_url = data.get('url') or data.get('payment_url') + expired_str = data.get('expired') + + if not payment_url: + # Без URL у пользователя нет способа оплатить — это аномалия Lava API. + # Row не сохраняем, чтобы не плодить «зависшие» pending-инвойсы без реквизитов. + logger.error( + 'Lava: ответ API без payment URL, инвойс не создан', + order_id=order_id, + lava_invoice_id=lava_invoice_id, + response_keys=list(data.keys()) if isinstance(data, dict) else None, + ) + return None + + logger.info( + 'Lava: получен ответ API', + order_id=order_id, + lava_invoice_id=lava_invoice_id, + payment_url=payment_url, + ) + + lifetime = settings.LAVA_PAYMENT_LIFETIME_MINUTES + expires_at = self._parse_lava_expired(expired_str) or (datetime.now(UTC) + timedelta(minutes=lifetime)) + + lava_crud = import_module('app.database.crud.lava') + local_payment = await lava_crud.create_lava_payment( + db=db, + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=method_key, + lava_invoice_id=str(lava_invoice_id) if lava_invoice_id else None, + expires_at=expires_at, + metadata_json=metadata, + ) + + logger.info( + 'Lava: создан платеж', + order_id=order_id, + user_id=user_id, + amount_rubles=amount_rubles, + currency=currency, + ) + + return { + 'order_id': order_id, + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_rubles, + 'currency': currency, + 'payment_url': payment_url, + 'payment_id': str(lava_invoice_id) if lava_invoice_id else None, + 'expires_at': expires_at.isoformat(), + 'local_payment_id': local_payment.id, + } + + except Exception as e: + logger.exception('Lava: ошибка создания платежа', error=e) + return None + + @staticmethod + def _parse_lava_expired(value: Any) -> datetime | None: + """Парсит поле ``expired`` из ответа Lava. + + Принимаются только TZ-aware строки (ISO с offset/Z) или unix timestamp. + Naive-строки игнорируются — TZ Lava в спеке не задокументирована, + а угадывание UTC может сместить срок жизни инвойса на несколько часов. + Если парсинг не удался — caller использует fallback ``now + lifetime``. + """ + if value is None or value == '': + return None + if isinstance(value, (int, float)): + try: + return datetime.fromtimestamp(float(value), tz=UTC) + except (ValueError, OSError): + return None + if isinstance(value, str): + try: + from dateutil.parser import isoparse # type: ignore[import-not-found] + + parsed = isoparse(value) + if parsed.tzinfo is None: + return None # без TZ доверять не можем + return parsed + except Exception: + return None + return None + + @staticmethod + def _build_lava_hook_url() -> str | None: + """Собирает абсолютный URL вебхука для Lava.""" + webhook_path = settings.LAVA_WEBHOOK_PATH or '/lava-webhook' + base = ( + getattr(settings, 'WEBHOOK_URL', None) + or getattr(settings, 'WEB_API_BASE_URL', None) + or getattr(settings, 'CABINET_URL', None) + ) + if not base: + return None + suffix = webhook_path if webhook_path.startswith('/') else f'/{webhook_path}' + return f'{base.rstrip("/")}{suffix}' + + async def process_lava_callback( + self, + db: AsyncSession, + payload: dict[str, Any], + ) -> bool: + """Обрабатывает webhook от Lava (подпись уже проверена в webserver).""" + try: + lava_invoice_id = payload.get('invoice_id') + our_order_id = payload.get('order_id') + lava_status = (payload.get('status') or '').strip().lower() + pay_service = payload.get('pay_service') + + if not our_order_id or not lava_status: + logger.warning('Lava webhook: отсутствуют обязательные поля') + return False + + lava_crud = import_module('app.database.crud.lava') + payment = await lava_crud.get_lava_payment_by_order_id(db, our_order_id) + if not payment: + # Fallback по invoice_id, но строго проверяем совпадение order_id + if lava_invoice_id: + payment = await lava_crud.get_lava_payment_by_invoice_id(db, str(lava_invoice_id)) + if payment and payment.order_id != our_order_id: + logger.error( + 'Lava webhook: order_id mismatch', + webhook_order_id=our_order_id, + record_order_id=payment.order_id, + invoice_id=lava_invoice_id, + ) + return False + if not payment: + logger.warning('Lava webhook: платеж не найден', order_id=our_order_id) + return False + + locked = await lava_crud.get_lava_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('Lava: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + + if payment.is_paid: + logger.info('Lava webhook: платеж уже обработан', order_id=payment.order_id) + return True + + # Терминальные неуспешные статусы — стики, защита от повторного успеха + if payment.status in {'amount_mismatch', 'cancelled', 'cancel', 'error', 'expired', 'failed'}: + # Если внезапно пришёл success после терминальной неудачи — это сигнал + # подделки или ошибки на стороне Lava, эскалируем. + if lava_status == 'success': + logger.error( + 'Lava webhook: success на терминально-неуспешном платеже, игнорируется', + order_id=payment.order_id, + current_status=payment.status, + ) + else: + logger.warning( + 'Lava webhook: платёж в терминальном неуспешном статусе, игнорируется', + order_id=payment.order_id, + current_status=payment.status, + incoming_status=lava_status, + ) + return True + + if lava_status not in LAVA_STATUS_MAP: + logger.warning( + 'Lava webhook: неизвестный статус, обрабатываем как pending', + order_id=payment.order_id, + incoming_status=lava_status, + ) + internal_status, is_paid = LAVA_STATUS_MAP.get(lava_status, ('pending', False)) + + callback_payload = { + 'lava_invoice_id': lava_invoice_id, + 'status': lava_status, + 'amount': payload.get('amount'), + 'credited': payload.get('credited'), + 'pay_service': pay_service, + 'pay_time': payload.get('pay_time'), + 'payer_details': payload.get('payer_details'), + 'custom_fields': payload.get('custom_fields'), + } + + # Сверяем сумму ДО зачисления + if is_paid: + # Lava webhook содержит amount (сумма счёта в рублях, float). + # Сверяем с тем, что мы отправляли на создание. + received_amount = payload.get('amount') + if received_amount is not None: + try: + received_kopeks = round(float(received_amount) * 100) + except (TypeError, ValueError): + received_kopeks = None + if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1: + logger.error( + 'Lava amount mismatch', + expected_kopeks=payment.amount_kopeks, + received_kopeks=received_kopeks, + order_id=payment.order_id, + ) + await lava_crud.update_lava_payment_status( + db=db, + payment=payment, + status='amount_mismatch', + is_paid=False, + callback_payload=callback_payload, + ) + return False + + if is_paid: + payment.status = internal_status + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + if lava_invoice_id and not payment.lava_invoice_id: + payment.lava_invoice_id = str(lava_invoice_id) + # Сохраняем pay_service в metadata, не перезаписывая user-выбранный payment_method + if pay_service: + metadata_now = dict(getattr(payment, 'metadata_json', {}) or {}) + metadata_now['actual_pay_service'] = str(pay_service).lower() + payment.metadata_json = metadata_now + payment.callback_payload = callback_payload + payment.updated_at = datetime.now(UTC) + await db.flush() + return await self._finalize_lava_payment(db, payment, trigger='webhook') + + payment = await lava_crud.update_lava_payment_status( + db=db, + payment=payment, + status=internal_status, + is_paid=False, + callback_payload=callback_payload, + ) + return True + + except Exception as e: + logger.exception('Lava webhook: ошибка обработки', error=e) + return False + + async def _finalize_lava_payment( + self, + db: AsyncSession, + payment: Any, + *, + trigger: str, + ) -> bool: + """Создаёт транзакцию, начисляет баланс и отправляет уведомления. + + FOR UPDATE-lock уже взят вызывающим. + """ + payment_module = import_module('app.services.payment_service') + lava_crud = import_module('app.database.crud.lava') + + if payment.transaction_id: + logger.info( + 'Lava платеж уже связан с транзакцией', + order_id=payment.order_id, + transaction_id=payment.transaction_id, + trigger=trigger, + ) + return True + + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=metadata, + payment_amount_kopeks=payment.amount_kopeks, + provider_payment_id=payment.order_id, + provider_name='lava', + ) + if guest_result is not None: + return True + + if not payment.is_paid: + payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.updated_at = datetime.now(UTC) + + balance_already_credited = bool(metadata.get('balance_credited')) + + user = await payment_module.get_user_by_id(db, payment.user_id) + if not user: + logger.error('Пользователь не найден для Lava', user_id=payment.user_id) + return False + + await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups']) + for user_promo_group in getattr(user, 'user_promo_groups', []): + await db.refresh(user_promo_group, attribute_names=['promo_group']) + + promo_group = user.get_primary_promo_group() + subscription = getattr(user, 'subscription', None) + referrer_info = format_referrer_info(user) + + transaction_external_id = payment.order_id + + existing_transaction = None + if transaction_external_id: + existing_transaction = await payment_module.get_transaction_by_external_id( + db, + transaction_external_id, + PaymentMethod.LAVA, + ) + + display_name = settings.get_lava_display_name() + description = f'Пополнение через {display_name}' + + transaction = existing_transaction + created_transaction = False + + if not transaction: + transaction = await payment_module.create_transaction( + db, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + amount_kopeks=payment.amount_kopeks, + description=description, + payment_method=PaymentMethod.LAVA, + external_id=transaction_external_id, + is_completed=True, + created_at=getattr(payment, 'created_at', None), + commit=False, + ) + created_transaction = True + + await lava_crud.link_lava_payment_to_transaction(db, payment=payment, transaction_id=transaction.id) + + should_credit_balance = created_transaction or not balance_already_credited + + if not should_credit_balance: + logger.info('Lava платеж уже зачислил баланс ранее', order_id=payment.order_id) + return True + + from app.database.crud.user import lock_user_for_update + + user = await lock_user_for_update(db, user) + + old_balance = user.balance_kopeks + was_first_topup = not user.has_made_first_topup + + user.balance_kopeks += payment.amount_kopeks + user.updated_at = datetime.now(UTC) + await db.commit() + await db.refresh(user) + + from app.database.crud.transaction import emit_transaction_side_effects + + await emit_transaction_side_effects( + db, + transaction, + amount_kopeks=payment.amount_kopeks, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + payment_method=PaymentMethod.LAVA, + external_id=transaction_external_id, + ) + + topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение' + + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup( + db, + user.id, + payment.amount_kopeks, + getattr(self, 'bot', None), + ) + except Exception as error: + logger.error('Ошибка обработки реферального пополнения Lava', error=error) + + if was_first_topup and not user.has_made_first_topup and not user.referred_by_id: + user.has_made_first_topup = True + await db.commit() + await db.refresh(user) + + if getattr(self, 'bot', None): + try: + from app.services.admin_notification_service import AdminNotificationService + + notification_service = AdminNotificationService(self.bot) + await notification_service.send_balance_topup_notification( + user, + transaction, + old_balance, + topup_status=topup_status, + referrer_info=referrer_info, + subscription=subscription, + promo_group=promo_group, + db=db, + ) + except Exception as error: + logger.error('Ошибка отправки админ уведомления Lava', error=error) + + if getattr(self, 'bot', None) and user.telegram_id: + try: + keyboard = await self.build_topup_success_keyboard(user) + await self.bot.send_message( + user.telegram_id, + ( + '✅ Пополнение успешно!\n\n' + f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n' + f'\U0001f4b3 Способ: {display_name}\n' + f'\U0001f194 Транзакция: {transaction.id}\n\n' + 'Баланс пополнен автоматически!' + ), + parse_mode='HTML', + reply_markup=keyboard, + ) + except Exception as error: + logger.error('Ошибка отправки уведомления пользователю Lava', error=error) + + try: + from app.services.payment.common import send_cart_notification_after_topup + + await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None)) + except Exception as error: + logger.error( + 'Ошибка при работе с сохраненной корзиной для пользователя', + user_id=payment.user_id, + error=error, + exc_info=True, + ) + + metadata['balance_change'] = { + 'old_balance': old_balance, + 'new_balance': user.balance_kopeks, + 'credited_at': datetime.now(UTC).isoformat(), + } + metadata['balance_credited'] = True + payment.metadata_json = metadata + await db.commit() + + logger.info( + 'Обработан Lava платеж', + order_id=payment.order_id, + user_id=payment.user_id, + trigger=trigger, + ) + + return True + + async def check_lava_payment_status( + self, + db: AsyncSession, + order_id: str | None = None, + invoice_id: str | None = None, + ) -> dict[str, Any] | None: + """Запрос статуса инвойса через API Lava.""" + try: + return await lava_service.get_invoice_status(order_id=order_id, invoice_id=invoice_id) + except Exception as e: + logger.error( + 'Lava: ошибка проверки статуса', + order_id=order_id, + invoice_id=invoice_id, + error=e, + ) + return None diff --git a/app/services/payment_method_config_service.py b/app/services/payment_method_config_service.py index 35f21da6..6766ac6b 100644 --- a/app/services/payment_method_config_service.py +++ b/app/services/payment_method_config_service.py @@ -189,6 +189,57 @@ def _get_method_defaults() -> dict: {'id': 'sbp', 'name': 'СБП'}, ], }, + 'etoplatezhi': { + 'default_display_name': settings.get_etoplatezhi_display_name(), + 'is_configured': settings.is_etoplatezhi_enabled(), + 'default_min': settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS, + 'default_max': settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS, + 'available_sub_options': [ + {'id': 'card', 'name': 'Карта'}, + {'id': 'sbp', 'name': 'СБП'}, + ], + }, + 'antilopay': { + 'default_display_name': settings.get_antilopay_display_name(), + 'is_configured': settings.is_antilopay_enabled(), + 'default_min': settings.ANTILOPAY_MIN_AMOUNT_KOPEKS, + 'default_max': settings.ANTILOPAY_MAX_AMOUNT_KOPEKS, + 'available_sub_options': [ + {'id': 'card', 'name': 'Карта'}, + {'id': 'sbp', 'name': 'СБП'}, + {'id': 'sberpay', 'name': 'SberPay'}, + ], + }, + 'jupiter': { + 'default_display_name': settings.get_jupiter_display_name(), + 'is_configured': settings.is_jupiter_enabled(), + 'default_min': settings.JUPITER_MIN_AMOUNT_KOPEKS, + 'default_max': settings.JUPITER_MAX_AMOUNT_KOPEKS, + 'available_sub_options': [ + {'id': 'sbp', 'name': 'СБП'}, + ], + }, + 'donut': { + 'default_display_name': settings.get_donut_display_name(), + 'is_configured': settings.is_donut_enabled(), + 'default_min': settings.DONUT_MIN_AMOUNT_KOPEKS, + 'default_max': settings.DONUT_MAX_AMOUNT_KOPEKS, + 'available_sub_options': [ + {'id': 'card', 'name': 'Карта'}, + {'id': 'sbp', 'name': 'СБП'}, + {'id': 'sbp_qr', 'name': 'СБП QR'}, + ], + }, + 'lava': { + 'default_display_name': settings.get_lava_display_name(), + 'is_configured': settings.is_lava_enabled(), + 'default_min': settings.LAVA_MIN_AMOUNT_KOPEKS, + 'default_max': settings.LAVA_MAX_AMOUNT_KOPEKS, + 'available_sub_options': [ + {'id': 'card', 'name': 'Карта'}, + {'id': 'sbp', 'name': 'СБП'}, + ], + }, } @@ -235,6 +286,11 @@ DEFAULT_METHOD_ORDER = [ 'rollypay', 'overpay', 'aurapay', + 'etoplatezhi', + 'antilopay', + 'jupiter', + 'donut', + 'lava', ] diff --git a/app/services/payment_search_service.py b/app/services/payment_search_service.py index b8418365..af3124b0 100644 --- a/app/services/payment_search_service.py +++ b/app/services/payment_search_service.py @@ -15,17 +15,25 @@ from sqlalchemy.orm import selectinload from sqlalchemy.types import String as SAString from app.database.models import ( + AntilopayPayment, + AuraPayPayment, CloudPaymentsPayment, CryptoBotPayment, + DonutPayment, + EtoplatezhiPayment, FreekassaPayment, HeleketPayment, + JupiterPayment, KassaAiPayment, + LavaPayment, MulenPayPayment, OverpayPayment, Pal24Payment, PaymentMethod, + PayPearPayment, PlategaPayment, RioPayPayment, + RollyPayPayment, SeverPayPayment, Transaction, TransactionType, @@ -683,6 +691,280 @@ async def _search_overpay(db: AsyncSession, params: SearchParams) -> list[Pendin return records +async def _search_paypear(db: AsyncSession, params: SearchParams) -> list[PendingPayment]: + stmt = select(PayPearPayment).options(selectinload(PayPearPayment.user)).order_by(desc(PayPearPayment.created_at)) + stmt = _apply_date_filter(stmt, PayPearPayment.created_at, params.cutoff, params.upper_bound) + + if params.search: + kind = _detect_user_search_kind(params.search) + if kind == _UserSearchKind.INVOICE: + conditions = [ + PayPearPayment.order_id.ilike(f'%{_escape_like(params.search)}%'), + PayPearPayment.paypear_id.ilike(f'%{_escape_like(params.search)}%'), + ] + stmt = stmt.where(or_(*conditions)) + else: + stmt = _apply_user_join_filter(stmt, PayPearPayment, kind, params.search) + + stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + record = _build_record( + PaymentMethod.PAYPEAR, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + +async def _search_rollypay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]: + stmt = ( + select(RollyPayPayment).options(selectinload(RollyPayPayment.user)).order_by(desc(RollyPayPayment.created_at)) + ) + stmt = _apply_date_filter(stmt, RollyPayPayment.created_at, params.cutoff, params.upper_bound) + + if params.search: + kind = _detect_user_search_kind(params.search) + if kind == _UserSearchKind.INVOICE: + conditions = [ + RollyPayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'), + RollyPayPayment.rollypay_payment_id.ilike(f'%{_escape_like(params.search)}%'), + ] + stmt = stmt.where(or_(*conditions)) + else: + stmt = _apply_user_join_filter(stmt, RollyPayPayment, kind, params.search) + + stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + record = _build_record( + PaymentMethod.ROLLYPAY, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + +async def _search_aurapay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]: + stmt = select(AuraPayPayment).options(selectinload(AuraPayPayment.user)).order_by(desc(AuraPayPayment.created_at)) + stmt = _apply_date_filter(stmt, AuraPayPayment.created_at, params.cutoff, params.upper_bound) + + if params.search: + kind = _detect_user_search_kind(params.search) + if kind == _UserSearchKind.INVOICE: + conditions = [ + AuraPayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'), + AuraPayPayment.aurapay_invoice_id.ilike(f'%{_escape_like(params.search)}%'), + ] + stmt = stmt.where(or_(*conditions)) + else: + stmt = _apply_user_join_filter(stmt, AuraPayPayment, kind, params.search) + + stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + record = _build_record( + PaymentMethod.AURAPAY, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + +async def _search_etoplatezhi(db: AsyncSession, params: SearchParams) -> list[PendingPayment]: + stmt = ( + select(EtoplatezhiPayment) + .options(selectinload(EtoplatezhiPayment.user)) + .order_by(desc(EtoplatezhiPayment.created_at)) + ) + stmt = _apply_date_filter(stmt, EtoplatezhiPayment.created_at, params.cutoff, params.upper_bound) + + if params.search: + kind = _detect_user_search_kind(params.search) + if kind == _UserSearchKind.INVOICE: + conditions = [ + EtoplatezhiPayment.order_id.ilike(f'%{_escape_like(params.search)}%'), + EtoplatezhiPayment.etoplatezhi_payment_id.ilike(f'%{_escape_like(params.search)}%'), + ] + stmt = stmt.where(or_(*conditions)) + else: + stmt = _apply_user_join_filter(stmt, EtoplatezhiPayment, kind, params.search) + + stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + record = _build_record( + PaymentMethod.ETOPLATEZHI, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + +async def _search_antilopay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]: + stmt = ( + select(AntilopayPayment) + .options(selectinload(AntilopayPayment.user)) + .order_by(desc(AntilopayPayment.created_at)) + ) + stmt = _apply_date_filter(stmt, AntilopayPayment.created_at, params.cutoff, params.upper_bound) + + if params.search: + kind = _detect_user_search_kind(params.search) + if kind == _UserSearchKind.INVOICE: + conditions = [ + AntilopayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'), + AntilopayPayment.antilopay_payment_id.ilike(f'%{_escape_like(params.search)}%'), + ] + stmt = stmt.where(or_(*conditions)) + else: + stmt = _apply_user_join_filter(stmt, AntilopayPayment, kind, params.search) + + stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + record = _build_record( + PaymentMethod.ANTILOPAY, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + +async def _search_jupiter(db: AsyncSession, params: SearchParams) -> list[PendingPayment]: + stmt = select(JupiterPayment).options(selectinload(JupiterPayment.user)).order_by(desc(JupiterPayment.created_at)) + stmt = _apply_date_filter(stmt, JupiterPayment.created_at, params.cutoff, params.upper_bound) + + if params.search: + kind = _detect_user_search_kind(params.search) + if kind == _UserSearchKind.INVOICE: + conditions = [ + JupiterPayment.order_id.ilike(f'%{_escape_like(params.search)}%'), + JupiterPayment.jupiter_transaction_id.ilike(f'%{_escape_like(params.search)}%'), + ] + stmt = stmt.where(or_(*conditions)) + else: + stmt = _apply_user_join_filter(stmt, JupiterPayment, kind, params.search) + + stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + record = _build_record( + PaymentMethod.JUPITER, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + +async def _search_donut(db: AsyncSession, params: SearchParams) -> list[PendingPayment]: + stmt = select(DonutPayment).options(selectinload(DonutPayment.user)).order_by(desc(DonutPayment.created_at)) + stmt = _apply_date_filter(stmt, DonutPayment.created_at, params.cutoff, params.upper_bound) + + if params.search: + kind = _detect_user_search_kind(params.search) + if kind == _UserSearchKind.INVOICE: + conditions = [ + DonutPayment.order_id.ilike(f'%{_escape_like(params.search)}%'), + DonutPayment.donut_transaction_id.ilike(f'%{_escape_like(params.search)}%'), + ] + stmt = stmt.where(or_(*conditions)) + else: + stmt = _apply_user_join_filter(stmt, DonutPayment, kind, params.search) + + stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + record = _build_record( + PaymentMethod.DONUT, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + +async def _search_lava(db: AsyncSession, params: SearchParams) -> list[PendingPayment]: + stmt = select(LavaPayment).options(selectinload(LavaPayment.user)).order_by(desc(LavaPayment.created_at)) + stmt = _apply_date_filter(stmt, LavaPayment.created_at, params.cutoff, params.upper_bound) + + if params.search: + kind = _detect_user_search_kind(params.search) + if kind == _UserSearchKind.INVOICE: + conditions = [ + LavaPayment.order_id.ilike(f'%{_escape_like(params.search)}%'), + LavaPayment.lava_invoice_id.ilike(f'%{_escape_like(params.search)}%'), + ] + stmt = stmt.where(or_(*conditions)) + else: + stmt = _apply_user_join_filter(stmt, LavaPayment, kind, params.search) + + stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + record = _build_record( + PaymentMethod.LAVA, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + async def _search_stars(db: AsyncSession, params: SearchParams) -> list[PendingPayment]: stmt = ( select(Transaction) @@ -737,6 +1019,14 @@ _PROVIDER_SEARCH_MAP: dict[PaymentMethod, Any] = { PaymentMethod.RIOPAY: _search_riopay, PaymentMethod.SEVERPAY: _search_severpay, PaymentMethod.OVERPAY: _search_overpay, + PaymentMethod.PAYPEAR: _search_paypear, + PaymentMethod.ROLLYPAY: _search_rollypay, + PaymentMethod.AURAPAY: _search_aurapay, + PaymentMethod.ETOPLATEZHI: _search_etoplatezhi, + PaymentMethod.ANTILOPAY: _search_antilopay, + PaymentMethod.JUPITER: _search_jupiter, + PaymentMethod.DONUT: _search_donut, + PaymentMethod.LAVA: _search_lava, PaymentMethod.TELEGRAM_STARS: _search_stars, } diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 47e09270..92faa941 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -30,10 +30,15 @@ from app.services.payment import ( WataPaymentMixin, YooKassaPaymentMixin, ) +from app.services.payment.antilopay import AntilopayPaymentMixin from app.services.payment.aurapay import AuraPayPaymentMixin from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin +from app.services.payment.donut import DonutPaymentMixin +from app.services.payment.etoplatezhi import EtoplatezhiPaymentMixin from app.services.payment.freekassa import FreekassaPaymentMixin +from app.services.payment.jupiter import JupiterPaymentMixin from app.services.payment.kassa_ai import KassaAiPaymentMixin +from app.services.payment.lava import LavaPaymentMixin from app.services.payment.overpay import OverpayPaymentMixin from app.services.payment.paypear import PayPearPaymentMixin from app.services.payment.riopay import RioPayPaymentMixin @@ -482,6 +487,181 @@ async def link_aurapay_payment_to_transaction(*args, **kwargs): return await aurapay_crud.link_aurapay_payment_to_transaction(*args, **kwargs) +async def create_etoplatezhi_payment(*args, **kwargs): + etoplatezhi_crud = import_module('app.database.crud.etoplatezhi') + return await etoplatezhi_crud.create_etoplatezhi_payment(*args, **kwargs) + + +async def get_etoplatezhi_payment_by_order_id(*args, **kwargs): + etoplatezhi_crud = import_module('app.database.crud.etoplatezhi') + return await etoplatezhi_crud.get_etoplatezhi_payment_by_order_id(*args, **kwargs) + + +async def get_etoplatezhi_payment_by_invoice_id(*args, **kwargs): + etoplatezhi_crud = import_module('app.database.crud.etoplatezhi') + return await etoplatezhi_crud.get_etoplatezhi_payment_by_invoice_id(*args, **kwargs) + + +async def get_etoplatezhi_payment_by_id(*args, **kwargs): + etoplatezhi_crud = import_module('app.database.crud.etoplatezhi') + return await etoplatezhi_crud.get_etoplatezhi_payment_by_id(*args, **kwargs) + + +async def get_etoplatezhi_payment_by_id_for_update(*args, **kwargs): + etoplatezhi_crud = import_module('app.database.crud.etoplatezhi') + return await etoplatezhi_crud.get_etoplatezhi_payment_by_id_for_update(*args, **kwargs) + + +async def update_etoplatezhi_payment_status(*args, **kwargs): + etoplatezhi_crud = import_module('app.database.crud.etoplatezhi') + return await etoplatezhi_crud.update_etoplatezhi_payment_status(*args, **kwargs) + + +async def link_etoplatezhi_payment_to_transaction(*args, **kwargs): + etoplatezhi_crud = import_module('app.database.crud.etoplatezhi') + return await etoplatezhi_crud.link_etoplatezhi_payment_to_transaction(*args, **kwargs) + + +async def create_antilopay_payment(*args, **kwargs): + antilopay_crud = import_module('app.database.crud.antilopay') + return await antilopay_crud.create_antilopay_payment(*args, **kwargs) + + +async def get_antilopay_payment_by_order_id(*args, **kwargs): + antilopay_crud = import_module('app.database.crud.antilopay') + return await antilopay_crud.get_antilopay_payment_by_order_id(*args, **kwargs) + + +async def get_antilopay_payment_by_invoice_id(*args, **kwargs): + antilopay_crud = import_module('app.database.crud.antilopay') + return await antilopay_crud.get_antilopay_payment_by_invoice_id(*args, **kwargs) + + +async def get_antilopay_payment_by_id(*args, **kwargs): + antilopay_crud = import_module('app.database.crud.antilopay') + return await antilopay_crud.get_antilopay_payment_by_id(*args, **kwargs) + + +async def get_antilopay_payment_by_id_for_update(*args, **kwargs): + antilopay_crud = import_module('app.database.crud.antilopay') + return await antilopay_crud.get_antilopay_payment_by_id_for_update(*args, **kwargs) + + +async def update_antilopay_payment_status(*args, **kwargs): + antilopay_crud = import_module('app.database.crud.antilopay') + return await antilopay_crud.update_antilopay_payment_status(*args, **kwargs) + + +async def link_antilopay_payment_to_transaction(*args, **kwargs): + antilopay_crud = import_module('app.database.crud.antilopay') + return await antilopay_crud.link_antilopay_payment_to_transaction(*args, **kwargs) + + +async def create_jupiter_payment(*args, **kwargs): + jupiter_crud = import_module('app.database.crud.jupiter') + return await jupiter_crud.create_jupiter_payment(*args, **kwargs) + + +async def get_jupiter_payment_by_order_id(*args, **kwargs): + jupiter_crud = import_module('app.database.crud.jupiter') + return await jupiter_crud.get_jupiter_payment_by_order_id(*args, **kwargs) + + +async def get_jupiter_payment_by_invoice_id(*args, **kwargs): + jupiter_crud = import_module('app.database.crud.jupiter') + return await jupiter_crud.get_jupiter_payment_by_invoice_id(*args, **kwargs) + + +async def get_jupiter_payment_by_id(*args, **kwargs): + jupiter_crud = import_module('app.database.crud.jupiter') + return await jupiter_crud.get_jupiter_payment_by_id(*args, **kwargs) + + +async def get_jupiter_payment_by_id_for_update(*args, **kwargs): + jupiter_crud = import_module('app.database.crud.jupiter') + return await jupiter_crud.get_jupiter_payment_by_id_for_update(*args, **kwargs) + + +async def update_jupiter_payment_status(*args, **kwargs): + jupiter_crud = import_module('app.database.crud.jupiter') + return await jupiter_crud.update_jupiter_payment_status(*args, **kwargs) + + +async def link_jupiter_payment_to_transaction(*args, **kwargs): + jupiter_crud = import_module('app.database.crud.jupiter') + return await jupiter_crud.link_jupiter_payment_to_transaction(*args, **kwargs) + + +async def create_donut_payment(*args, **kwargs): + donut_crud = import_module('app.database.crud.donut') + return await donut_crud.create_donut_payment(*args, **kwargs) + + +async def get_donut_payment_by_order_id(*args, **kwargs): + donut_crud = import_module('app.database.crud.donut') + return await donut_crud.get_donut_payment_by_order_id(*args, **kwargs) + + +async def get_donut_payment_by_invoice_id(*args, **kwargs): + donut_crud = import_module('app.database.crud.donut') + return await donut_crud.get_donut_payment_by_invoice_id(*args, **kwargs) + + +async def get_donut_payment_by_id(*args, **kwargs): + donut_crud = import_module('app.database.crud.donut') + return await donut_crud.get_donut_payment_by_id(*args, **kwargs) + + +async def get_donut_payment_by_id_for_update(*args, **kwargs): + donut_crud = import_module('app.database.crud.donut') + return await donut_crud.get_donut_payment_by_id_for_update(*args, **kwargs) + + +async def update_donut_payment_status(*args, **kwargs): + donut_crud = import_module('app.database.crud.donut') + return await donut_crud.update_donut_payment_status(*args, **kwargs) + + +async def link_donut_payment_to_transaction(*args, **kwargs): + donut_crud = import_module('app.database.crud.donut') + return await donut_crud.link_donut_payment_to_transaction(*args, **kwargs) + + +async def create_lava_payment(*args, **kwargs): + lava_crud = import_module('app.database.crud.lava') + return await lava_crud.create_lava_payment(*args, **kwargs) + + +async def get_lava_payment_by_order_id(*args, **kwargs): + lava_crud = import_module('app.database.crud.lava') + return await lava_crud.get_lava_payment_by_order_id(*args, **kwargs) + + +async def get_lava_payment_by_invoice_id(*args, **kwargs): + lava_crud = import_module('app.database.crud.lava') + return await lava_crud.get_lava_payment_by_invoice_id(*args, **kwargs) + + +async def get_lava_payment_by_id(*args, **kwargs): + lava_crud = import_module('app.database.crud.lava') + return await lava_crud.get_lava_payment_by_id(*args, **kwargs) + + +async def get_lava_payment_by_id_for_update(*args, **kwargs): + lava_crud = import_module('app.database.crud.lava') + return await lava_crud.get_lava_payment_by_id_for_update(*args, **kwargs) + + +async def update_lava_payment_status(*args, **kwargs): + lava_crud = import_module('app.database.crud.lava') + return await lava_crud.update_lava_payment_status(*args, **kwargs) + + +async def link_lava_payment_to_transaction(*args, **kwargs): + lava_crud = import_module('app.database.crud.lava') + return await lava_crud.link_lava_payment_to_transaction(*args, **kwargs) + + # Mapping from model_name to getter function name for providers # where it differs from the standard get_{model_name}_payment_by_id pattern. _GETTER_OVERRIDES: dict[str, str] = { @@ -509,6 +689,11 @@ class PaymentService( RollyPayPaymentMixin, OverpayPaymentMixin, AuraPayPaymentMixin, + EtoplatezhiPaymentMixin, + AntilopayPaymentMixin, + JupiterPaymentMixin, + DonutPaymentMixin, + LavaPaymentMixin, ): """Основной интерфейс платежей, делегирующий работу специализированным mixin-ам.""" @@ -1016,6 +1201,116 @@ class PaymentService( } return None + # --- Etoplatezhi ------------------------------------------------------ + if payment_method == 'etoplatezhi': + if not settings.is_etoplatezhi_enabled(): + logger.warning('Etoplatezhi is not enabled, cannot create guest payment') + return None + + result = await self.create_etoplatezhi_payment( + db=db, + user_id=None, + amount_kopeks=amount_kopeks, + description=description, + return_url=return_url, + ) + if result: + await _patch_guest_metadata(result['local_payment_id'], 'etoplatezhi') + return { + 'payment_url': result.get('payment_url'), + 'payment_id': result.get('order_id'), + 'provider': 'etoplatezhi', + } + return None + + # --- Antilopay -------------------------------------------------------- + if payment_method == 'antilopay': + if not settings.is_antilopay_enabled(): + logger.warning('Antilopay is not enabled, cannot create guest payment') + return None + + result = await self.create_antilopay_payment( + db=db, + user_id=None, + amount_kopeks=amount_kopeks, + description=description, + return_url=return_url, + ) + if result: + await _patch_guest_metadata(result['local_payment_id'], 'antilopay') + return { + 'payment_url': result.get('payment_url'), + 'payment_id': result.get('order_id'), + 'provider': 'antilopay', + } + return None + + # --- Jupiter ---------------------------------------------------------- + if payment_method == 'jupiter': + if not settings.is_jupiter_enabled(): + logger.warning('Jupiter is not enabled, cannot create guest payment') + return None + + result = await self.create_jupiter_payment( + db=db, + user_id=None, + amount_kopeks=amount_kopeks, + description=description, + return_url=return_url, + ) + if result: + await _patch_guest_metadata(result['local_payment_id'], 'jupiter') + return { + 'payment_url': result.get('payment_url'), + 'payment_id': result.get('order_id'), + 'provider': 'jupiter', + } + return None + + # --- Donut ------------------------------------------------------------ + if payment_method == 'donut': + if not settings.is_donut_enabled(): + logger.warning('Donut is not enabled, cannot create guest payment') + return None + + result = await self.create_donut_payment( + db=db, + user_id=None, + amount_kopeks=amount_kopeks, + description=description, + return_url=return_url, + ) + if result: + await _patch_guest_metadata(result['local_payment_id'], 'donut') + return { + 'payment_url': result.get('payment_url'), + 'payment_id': result.get('order_id'), + 'provider': 'donut', + } + return None + + # --- Lava ------------------------------------------------------------- + if payment_method == 'lava': + if not settings.is_lava_enabled(): + logger.warning('Lava is not enabled, cannot create guest payment') + return None + + result = await self.create_lava_payment( + db=db, + user_id=None, + amount_kopeks=amount_kopeks, + description=description, + return_url=return_url, + ) + if result: + await _patch_guest_metadata(result['local_payment_id'], 'lava') + return { + 'payment_url': result.get('payment_url'), + 'payment_id': result.get('order_id'), + 'provider': 'lava', + } + return None + # --- Telegram Stars --------------------------------------------------- if payment_method == 'telegram_stars': if not settings.TELEGRAM_STARS_ENABLED: diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py index 678b69da..31991c72 100644 --- a/app/services/payment_verification_service.py +++ b/app/services/payment_verification_service.py @@ -18,16 +18,24 @@ from sqlalchemy.orm import selectinload from app.config import settings from app.database.database import AsyncSessionLocal from app.database.models import ( + AntilopayPayment, + AuraPayPayment, CloudPaymentsPayment, CryptoBotPayment, + DonutPayment, + EtoplatezhiPayment, FreekassaPayment, HeleketPayment, + JupiterPayment, KassaAiPayment, + LavaPayment, MulenPayPayment, Pal24Payment, PaymentMethod, + PayPearPayment, PlategaPayment, RioPayPayment, + RollyPayPayment, SeverPayPayment, Transaction, TransactionType, @@ -77,6 +85,11 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset( PaymentMethod.RIOPAY, PaymentMethod.SEVERPAY, PaymentMethod.OVERPAY, + PaymentMethod.PAYPEAR, + PaymentMethod.ROLLYPAY, + PaymentMethod.AURAPAY, + # ETOPLATEZHI / ANTILOPAY / JUPITER / DONUT / LAVA — webhook-driven, + # без API-метода синхронизации БД, manual check не реализован. } ) @@ -98,6 +111,9 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset( PaymentMethod.RIOPAY, PaymentMethod.SEVERPAY, PaymentMethod.OVERPAY, + PaymentMethod.PAYPEAR, + PaymentMethod.ROLLYPAY, + PaymentMethod.AURAPAY, } ) @@ -129,6 +145,22 @@ def method_display_name(method: PaymentMethod) -> str: return settings.get_severpay_display_name() if method == PaymentMethod.OVERPAY: return settings.get_overpay_display_name() + if method == PaymentMethod.PAYPEAR: + return settings.get_paypear_display_name() + if method == PaymentMethod.ROLLYPAY: + return settings.get_rollypay_display_name() + if method == PaymentMethod.AURAPAY: + return settings.get_aurapay_display_name() + if method == PaymentMethod.ETOPLATEZHI: + return settings.get_etoplatezhi_display_name() + if method == PaymentMethod.ANTILOPAY: + return settings.get_antilopay_display_name() + if method == PaymentMethod.JUPITER: + return settings.get_jupiter_display_name() + if method == PaymentMethod.DONUT: + return settings.get_donut_display_name() + if method == PaymentMethod.LAVA: + return settings.get_lava_display_name() if method == PaymentMethod.TELEGRAM_STARS: return 'Telegram Stars' return method.value @@ -161,6 +193,22 @@ def _method_is_enabled(method: PaymentMethod) -> bool: return settings.is_severpay_enabled() if method == PaymentMethod.OVERPAY: return settings.is_overpay_enabled() + if method == PaymentMethod.PAYPEAR: + return settings.is_paypear_enabled() + if method == PaymentMethod.ROLLYPAY: + return settings.is_rollypay_enabled() + if method == PaymentMethod.AURAPAY: + return settings.is_aurapay_enabled() + if method == PaymentMethod.ETOPLATEZHI: + return settings.is_etoplatezhi_enabled() + if method == PaymentMethod.ANTILOPAY: + return settings.is_antilopay_enabled() + if method == PaymentMethod.JUPITER: + return settings.is_jupiter_enabled() + if method == PaymentMethod.DONUT: + return settings.is_donut_enabled() + if method == PaymentMethod.LAVA: + return settings.is_lava_enabled() return False @@ -410,6 +458,62 @@ def _is_riopay_pending(payment: RioPayPayment) -> bool: return status in {'pending'} +def _is_paypear_pending(payment: PayPearPayment) -> bool: + if payment.is_paid: + return False + status = (payment.status or '').lower() + return status in {'pending', 'created', 'processing'} + + +def _is_rollypay_pending(payment: RollyPayPayment) -> bool: + if payment.is_paid: + return False + status = (payment.status or '').lower() + return status in {'pending', 'created', 'processing'} + + +def _is_aurapay_pending(payment: AuraPayPayment) -> bool: + if payment.is_paid: + return False + status = (payment.status or '').lower() + return status in {'pending', 'created', 'processing'} + + +def _is_etoplatezhi_pending(payment: EtoplatezhiPayment) -> bool: + if payment.is_paid: + return False + status = (payment.status or '').lower() + return status in {'pending', 'created', 'processing'} + + +def _is_antilopay_pending(payment: AntilopayPayment) -> bool: + if payment.is_paid: + return False + status = (payment.status or '').lower() + return status in {'pending', 'created', 'processing'} + + +def _is_jupiter_pending(payment: JupiterPayment) -> bool: + if payment.is_paid: + return False + status = (payment.status or '').lower() + return status in {'pending', 'created', 'processing'} + + +def _is_donut_pending(payment: DonutPayment) -> bool: + if payment.is_paid: + return False + status = (payment.status or '').lower() + return status in {'pending', 'created', 'processing'} + + +def _is_lava_pending(payment: LavaPayment) -> 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) @@ -779,6 +883,214 @@ async def _fetch_severpay_payments(db: AsyncSession, cutoff: datetime) -> list[P return records +async def _fetch_paypear_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: + stmt = ( + select(PayPearPayment) + .options(selectinload(PayPearPayment.user)) + .where(PayPearPayment.created_at >= cutoff) + .order_by(desc(PayPearPayment.created_at)) + ) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_paypear_pending(payment): + continue + record = _build_record( + PaymentMethod.PAYPEAR, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + +async def _fetch_rollypay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: + stmt = ( + select(RollyPayPayment) + .options(selectinload(RollyPayPayment.user)) + .where(RollyPayPayment.created_at >= cutoff) + .order_by(desc(RollyPayPayment.created_at)) + ) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_rollypay_pending(payment): + continue + record = _build_record( + PaymentMethod.ROLLYPAY, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + +async def _fetch_aurapay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: + stmt = ( + select(AuraPayPayment) + .options(selectinload(AuraPayPayment.user)) + .where(AuraPayPayment.created_at >= cutoff) + .order_by(desc(AuraPayPayment.created_at)) + ) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_aurapay_pending(payment): + continue + record = _build_record( + PaymentMethod.AURAPAY, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + +async def _fetch_etoplatezhi_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: + stmt = ( + select(EtoplatezhiPayment) + .options(selectinload(EtoplatezhiPayment.user)) + .where(EtoplatezhiPayment.created_at >= cutoff) + .order_by(desc(EtoplatezhiPayment.created_at)) + ) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_etoplatezhi_pending(payment): + continue + record = _build_record( + PaymentMethod.ETOPLATEZHI, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + +async def _fetch_antilopay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: + stmt = ( + select(AntilopayPayment) + .options(selectinload(AntilopayPayment.user)) + .where(AntilopayPayment.created_at >= cutoff) + .order_by(desc(AntilopayPayment.created_at)) + ) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_antilopay_pending(payment): + continue + record = _build_record( + PaymentMethod.ANTILOPAY, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + +async def _fetch_jupiter_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: + stmt = ( + select(JupiterPayment) + .options(selectinload(JupiterPayment.user)) + .where(JupiterPayment.created_at >= cutoff) + .order_by(desc(JupiterPayment.created_at)) + ) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_jupiter_pending(payment): + continue + record = _build_record( + PaymentMethod.JUPITER, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + +async def _fetch_donut_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: + stmt = ( + select(DonutPayment) + .options(selectinload(DonutPayment.user)) + .where(DonutPayment.created_at >= cutoff) + .order_by(desc(DonutPayment.created_at)) + ) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_donut_pending(payment): + continue + record = _build_record( + PaymentMethod.DONUT, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + +async def _fetch_lava_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: + stmt = ( + select(LavaPayment) + .options(selectinload(LavaPayment.user)) + .where(LavaPayment.created_at >= cutoff) + .order_by(desc(LavaPayment.created_at)) + ) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_lava_pending(payment): + continue + record = _build_record( + PaymentMethod.LAVA, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: stmt = ( select(Transaction) @@ -828,6 +1140,14 @@ async def list_recent_pending_payments( await _fetch_kassa_ai_payments(db, cutoff), await _fetch_riopay_payments(db, cutoff), await _fetch_severpay_payments(db, cutoff), + await _fetch_paypear_payments(db, cutoff), + await _fetch_rollypay_payments(db, cutoff), + await _fetch_aurapay_payments(db, cutoff), + await _fetch_etoplatezhi_payments(db, cutoff), + await _fetch_antilopay_payments(db, cutoff), + await _fetch_jupiter_payments(db, cutoff), + await _fetch_donut_payments(db, cutoff), + await _fetch_lava_payments(db, cutoff), await _fetch_stars_transactions(db, cutoff), ) @@ -1026,6 +1346,126 @@ async def get_payment_record( expires_at=getattr(payment, 'expires_at', None), ) + if method == PaymentMethod.PAYPEAR: + payment = await db.get(PayPearPayment, 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), + expires_at=getattr(payment, 'expires_at', None), + ) + + if method == PaymentMethod.ROLLYPAY: + payment = await db.get(RollyPayPayment, 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), + expires_at=getattr(payment, 'expires_at', None), + ) + + if method == PaymentMethod.AURAPAY: + payment = await db.get(AuraPayPayment, 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), + expires_at=getattr(payment, 'expires_at', None), + ) + + if method == PaymentMethod.ETOPLATEZHI: + payment = await db.get(EtoplatezhiPayment, 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), + expires_at=getattr(payment, 'expires_at', None), + ) + + if method == PaymentMethod.ANTILOPAY: + payment = await db.get(AntilopayPayment, 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), + expires_at=getattr(payment, 'expires_at', None), + ) + + if method == PaymentMethod.JUPITER: + payment = await db.get(JupiterPayment, 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), + expires_at=getattr(payment, 'expires_at', None), + ) + + if method == PaymentMethod.DONUT: + payment = await db.get(DonutPayment, 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), + expires_at=getattr(payment, 'expires_at', None), + ) + + if method == PaymentMethod.LAVA: + payment = await db.get(LavaPayment, 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), + expires_at=getattr(payment, 'expires_at', None), + ) + if method == PaymentMethod.TELEGRAM_STARS: transaction = await db.get(Transaction, local_payment_id) if not transaction: @@ -1098,6 +1538,27 @@ async def run_manual_check( payment = result.get('payment') if result else None else: payment = None + elif method == PaymentMethod.PAYPEAR: + paypear_payment = await db.get(PayPearPayment, local_payment_id) + if paypear_payment: + result = await payment_service.check_paypear_payment_status(db, paypear_payment.order_id) + payment = result.get('payment') if result else None + else: + payment = None + elif method == PaymentMethod.ROLLYPAY: + rollypay_payment = await db.get(RollyPayPayment, local_payment_id) + if rollypay_payment: + result = await payment_service.check_rollypay_payment_status(db, rollypay_payment.order_id) + payment = result.get('payment') if result else None + else: + payment = None + elif method == PaymentMethod.AURAPAY: + aurapay_payment = await db.get(AuraPayPayment, local_payment_id) + if aurapay_payment: + result = await payment_service.check_aurapay_payment_status(db, aurapay_payment.order_id) + payment = result.get('payment') if result else None + else: + payment = None else: logger.warning('Manual check requested for unsupported method', method=method) return None diff --git a/app/services/referral_service.py b/app/services/referral_service.py index d08e6e4d..7409cce3 100644 --- a/app/services/referral_service.py +++ b/app/services/referral_service.py @@ -99,6 +99,73 @@ async def clear_pending_referral(telegram_id: int) -> None: pass +# --------------------------------------------------------------------------- +# Pending campaign helpers (Redis) +# +# Mirrors pending_referral: lets us survive the case where /start +# stored campaign_id only in FSM, but the user opened the cabinet WebApp before +# completing bot registration. The cabinet auth route reads this as a fallback +# when the HTTP request didn't carry an explicit campaign_slug. +# --------------------------------------------------------------------------- +_PENDING_CAMPAIGN_TTL = 7 * 24 * 3600 # 7 days + + +async def save_pending_campaign(telegram_id: int, campaign_slug: str, campaign_id: int) -> bool: + """Save pending campaign attribution to Redis for a not-yet-registered user. + + Called from /start handler immediately after resolving an advertising campaign. + Picked up by the cabinet auth route if the user opens the WebApp before + completing the bot registration flow. + """ + client = _get_redis() + if client is None: + return False + try: + key = f'pending_campaign:{telegram_id}' + data = json.dumps({'campaign_slug': campaign_slug, 'campaign_id': campaign_id}) + await client.setex(key, _PENDING_CAMPAIGN_TTL, data) + logger.info( + 'Saved pending campaign to Redis', + telegram_id=telegram_id, + campaign_slug=campaign_slug, + campaign_id=campaign_id, + ) + return True + except Exception as exc: + logger.warning('Failed to save pending campaign to Redis', error=exc) + return False + + +async def get_pending_campaign(telegram_id: int) -> dict[str, str | int] | None: + """Get pending campaign from Redis. + + Returns ``{'campaign_slug': ..., 'campaign_id': ...}`` or ``None``. + """ + client = _get_redis() + if client is None: + return None + try: + key = f'pending_campaign:{telegram_id}' + data = await client.get(key) + if data: + return json.loads(data) + return None + except Exception as exc: + logger.warning('Failed to get pending campaign from Redis', error=exc) + return None + + +async def clear_pending_campaign(telegram_id: int) -> None: + """Clear pending campaign after successful application.""" + client = _get_redis() + if client is None: + return + try: + await client.delete(f'pending_campaign:{telegram_id}') + except Exception: + pass + + async def _is_commission_limit_reached(db: AsyncSession, referrer_id: int, referral_id: int) -> bool: """Проверяет, исчерпан ли лимит комиссионных платежей для пары реферер-реферал.""" if settings.REFERRAL_MAX_COMMISSION_PAYMENTS <= 0: diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 47b25c26..71d707c4 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -95,6 +95,11 @@ class BotConfigurationService: 'ROLLYPAY': '💳 RollyPay', 'OVERPAY': '💳 Overpay', 'AURAPAY': '💳 AuraPay', + 'ANTILOPAY': '🦌 Antilopay', + 'ETOPLATEZHI': '💳 Etoplatezhi', + 'JUPITER': '🪐 Jupiter', + 'DONUT': '🍩 Donut', + 'LAVA': '🌋 Lava', 'YOOKASSA': '🟣 YooKassa', 'PLATEGA': '💳 {platega_name}', 'TRIBUTE': '🎁 Tribute', @@ -159,6 +164,11 @@ class BotConfigurationService: 'ROLLYPAY': 'RollyPay: платёжный шлюз rollypay.io с СБП, картами и криптовалютой.', 'OVERPAY': 'Overpay: платёжный шлюз pay.overpay.io с mTLS и поддержкой карт и СБП.', 'AURAPAY': 'AuraPay: платёжный шлюз aurapay.tech с поддержкой карт и СБП.', + 'ANTILOPAY': 'Antilopay: lk.antilopay.com, оплата картой, СБП и SberPay.', + 'ETOPLATEZHI': 'Etoplatezhi: paymentpage.etoplatezhi.ru, оплата картой и через СБП.', + 'JUPITER': 'Jupiter (FPGate P2P v2.1): app.juppiter.tech, эквайринг СБП с HMAC-SHA256.', + 'DONUT': 'Donut P2P: gw.donut.business, P2P-оплата картой, СБП по телефону и QR.', + 'LAVA': 'Lava Business: gate.lava.ru, оплата картой и СБП с HMAC-SHA256 и подтверждением через webhook.', 'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.', 'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.', 'PAL24': 'PAL24 / PayPalych подключения и лимиты.', @@ -373,6 +383,11 @@ class BotConfigurationService: 'ROLLYPAY_': 'ROLLYPAY', 'OVERPAY_': 'OVERPAY', 'AURAPAY_': 'AURAPAY', + 'ANTILOPAY_': 'ANTILOPAY', + 'ETOPLATEZHI_': 'ETOPLATEZHI', + 'JUPITER_': 'JUPITER', + 'DONUT_': 'DONUT', + 'LAVA_': 'LAVA', 'PLATEGA_': 'PLATEGA', 'MULENPAY_': 'MULENPAY', 'PAL24_': 'PAL24', diff --git a/app/utils/payment_utils.py b/app/utils/payment_utils.py index 5cabdcac..973fe118 100644 --- a/app/utils/payment_utils.py +++ b/app/utils/payment_utils.py @@ -232,7 +232,35 @@ def get_available_payment_methods() -> list[dict[str, str]]: } ) - if settings.is_aurapay_enabled(): + if settings.is_aurapay_sbp_enabled(): + sbp_name = settings.get_aurapay_sbp_display_name() + methods.append( + { + 'id': 'aurapay_sbp', + 'name': sbp_name, + 'icon': '📱', + 'description': f'через {sbp_name}', + 'callback': 'topup_aurapay_sbp', + } + ) + + if settings.is_aurapay_card_enabled(): + card_name = settings.get_aurapay_card_display_name() + methods.append( + { + 'id': 'aurapay_card', + 'name': card_name, + 'icon': '💳', + 'description': f'через {card_name}', + 'callback': 'topup_aurapay_card', + } + ) + + if ( + settings.is_aurapay_enabled() + and not settings.is_aurapay_sbp_enabled() + and not settings.is_aurapay_card_enabled() + ): aurapay_name = settings.get_aurapay_display_name() methods.append( { @@ -244,6 +272,99 @@ def get_available_payment_methods() -> list[dict[str, str]]: } ) + if settings.is_etoplatezhi_sbp_enabled(): + sbp_name = settings.get_etoplatezhi_sbp_display_name() + methods.append( + { + 'id': 'etoplatezhi_sbp', + 'name': sbp_name, + 'icon': '📱', + 'description': f'через {sbp_name}', + 'callback': 'topup_etoplatezhi_sbp', + } + ) + + if settings.is_etoplatezhi_card_enabled(): + card_name = settings.get_etoplatezhi_card_display_name() + methods.append( + { + 'id': 'etoplatezhi_card', + 'name': card_name, + 'icon': '💳', + 'description': f'через {card_name}', + 'callback': 'topup_etoplatezhi_card', + } + ) + + if ( + settings.is_etoplatezhi_enabled() + and not settings.is_etoplatezhi_sbp_enabled() + and not settings.is_etoplatezhi_card_enabled() + ): + etoplatezhi_name = settings.get_etoplatezhi_display_name() + methods.append( + { + 'id': 'etoplatezhi', + 'name': etoplatezhi_name, + 'icon': '💳', + 'description': f'через {etoplatezhi_name}', + 'callback': 'topup_etoplatezhi', + } + ) + + if settings.is_antilopay_sbp_enabled(): + sbp_name = settings.get_antilopay_sbp_display_name() + methods.append( + { + 'id': 'antilopay_sbp', + 'name': sbp_name, + 'icon': '📱', + 'description': f'через {sbp_name}', + 'callback': 'topup_antilopay_sbp', + } + ) + + if settings.is_antilopay_card_enabled(): + card_name = settings.get_antilopay_card_display_name() + methods.append( + { + 'id': 'antilopay_card', + 'name': card_name, + 'icon': '💳', + 'description': f'через {card_name}', + 'callback': 'topup_antilopay_card', + } + ) + + if settings.is_antilopay_sberpay_enabled(): + sberpay_name = settings.get_antilopay_sberpay_display_name() + methods.append( + { + 'id': 'antilopay_sberpay', + 'name': sberpay_name, + 'icon': '💳', + 'description': f'через {sberpay_name}', + 'callback': 'topup_antilopay_sberpay', + } + ) + + if ( + settings.is_antilopay_enabled() + and not settings.is_antilopay_sbp_enabled() + and not settings.is_antilopay_card_enabled() + and not settings.is_antilopay_sberpay_enabled() + ): + antilopay_name = settings.get_antilopay_display_name() + methods.append( + { + 'id': 'antilopay', + 'name': antilopay_name, + 'icon': '💳', + 'description': f'через {antilopay_name}', + 'callback': 'topup_antilopay', + } + ) + if settings.is_support_topup_enabled(): methods.append( { @@ -381,6 +502,24 @@ def is_payment_method_available(method_id: str) -> bool: return settings.is_overpay_enabled() if method_id == 'aurapay': return settings.is_aurapay_enabled() + if method_id == 'aurapay_sbp': + return settings.is_aurapay_sbp_enabled() + if method_id == 'aurapay_card': + return settings.is_aurapay_card_enabled() + if method_id == 'etoplatezhi': + return settings.is_etoplatezhi_enabled() + if method_id == 'etoplatezhi_sbp': + return settings.is_etoplatezhi_sbp_enabled() + if method_id == 'etoplatezhi_card': + return settings.is_etoplatezhi_card_enabled() + if method_id == 'antilopay': + return settings.is_antilopay_enabled() + if method_id == 'antilopay_sbp': + return settings.is_antilopay_sbp_enabled() + if method_id == 'antilopay_card': + return settings.is_antilopay_card_enabled() + if method_id == 'antilopay_sberpay': + return settings.is_antilopay_sberpay_enabled() if method_id == 'support': return settings.is_support_topup_enabled() return False @@ -409,6 +548,15 @@ def get_payment_method_status() -> dict[str, bool]: 'rollypay': settings.is_rollypay_enabled(), 'overpay': settings.is_overpay_enabled(), 'aurapay': settings.is_aurapay_enabled(), + 'aurapay_sbp': settings.is_aurapay_sbp_enabled(), + 'aurapay_card': settings.is_aurapay_card_enabled(), + 'etoplatezhi': settings.is_etoplatezhi_enabled(), + 'etoplatezhi_sbp': settings.is_etoplatezhi_sbp_enabled(), + 'etoplatezhi_card': settings.is_etoplatezhi_card_enabled(), + 'antilopay': settings.is_antilopay_enabled(), + 'antilopay_sbp': settings.is_antilopay_sbp_enabled(), + 'antilopay_card': settings.is_antilopay_card_enabled(), + 'antilopay_sberpay': settings.is_antilopay_sberpay_enabled(), 'support': settings.is_support_topup_enabled(), } @@ -454,4 +602,8 @@ def get_enabled_payment_methods_count() -> int: count += 1 if settings.is_aurapay_enabled(): count += 1 + if settings.is_etoplatezhi_enabled(): + count += 1 + if settings.is_antilopay_enabled(): + count += 1 return count diff --git a/app/webserver/payments.py b/app/webserver/payments.py index b4e158d0..fb4b4793 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -1469,6 +1469,241 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute routes_registered = True + # Etoplatezhi webhook + if settings.is_etoplatezhi_enabled(): + + @router.get(settings.ETOPLATEZHI_WEBHOOK_PATH) + async def etoplatezhi_health() -> JSONResponse: + return JSONResponse( + { + 'status': 'ok', + 'service': 'etoplatezhi_webhook', + 'enabled': settings.is_etoplatezhi_enabled(), + } + ) + + @router.post(settings.ETOPLATEZHI_WEBHOOK_PATH) + async def etoplatezhi_webhook(request: Request) -> JSONResponse: + try: + raw_body = await request.body() + payload = json.loads(raw_body) + except Exception as parse_error: + logger.error('Etoplatezhi webhook: failed to parse JSON', parse_error=parse_error) + return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) + + # Подпись внутри JSON body (поле signature) + from app.services.etoplatezhi_service import etoplatezhi_service + + if not etoplatezhi_service.verify_callback_signature(payload): + logger.warning('Etoplatezhi webhook: invalid signature') + return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) + + try: + success = await _process_payment_service_callback( + payment_service, + payload, + 'process_etoplatezhi_callback', + ) + if not success: + logger.error( + 'Etoplatezhi webhook processing failed', + data=payload.get('payment', {}).get('id'), + ) + except Exception as e: + logger.exception('Etoplatezhi webhook processing error', error=e) + # Always return 200 — Etoplatezhi expects 200 for valid signature + return JSONResponse({'status': True}, status_code=status.HTTP_200_OK) + + routes_registered = True + + # Antilopay webhook + if settings.is_antilopay_enabled(): + + @router.get(settings.ANTILOPAY_WEBHOOK_PATH) + async def antilopay_health() -> JSONResponse: + return JSONResponse( + { + 'status': 'ok', + 'service': 'antilopay_webhook', + 'enabled': settings.is_antilopay_enabled(), + } + ) + + @router.post(settings.ANTILOPAY_WEBHOOK_PATH) + async def antilopay_webhook(request: Request) -> JSONResponse: + try: + raw_body = await request.body() + payload = json.loads(raw_body) + except Exception as parse_error: + logger.error('Antilopay webhook: failed to parse JSON', parse_error=parse_error) + return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) + + # Подпись в заголовке X-Apay-Callback, верифицируется публичным ключом + from app.services.antilopay_service import antilopay_service + + callback_signature = request.headers.get('X-Apay-Callback') or '' + if not antilopay_service.verify_callback_signature(raw_body, callback_signature): + logger.warning('Antilopay webhook: invalid signature') + return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) + + try: + success = await _process_payment_service_callback( + payment_service, + payload, + 'process_antilopay_callback', + ) + if not success: + logger.error( + 'Antilopay webhook processing failed', + data=payload.get('payment_id'), + ) + except Exception as e: + logger.exception('Antilopay webhook processing error', error=e) + # Always return 200 — Antilopay retries every 3min for 1hr on non-200 + return JSONResponse({'status': True}, status_code=status.HTTP_200_OK) + + routes_registered = True + + # Jupiter webhook (FPGate P2P v2.1) + if settings.is_jupiter_enabled(): + + @router.get(settings.JUPITER_WEBHOOK_PATH) + async def jupiter_health() -> JSONResponse: + return JSONResponse( + { + 'status': 'ok', + 'service': 'jupiter_webhook', + 'enabled': settings.is_jupiter_enabled(), + } + ) + + @router.post(settings.JUPITER_WEBHOOK_PATH) + async def jupiter_webhook(request: Request) -> JSONResponse: + try: + raw_body = await request.body() + payload = json.loads(raw_body) + except Exception as parse_error: + logger.error('Jupiter webhook: failed to parse JSON', parse_error=parse_error) + return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST) + + from app.services.jupiter_service import jupiter_service + + if not jupiter_service.verify_callback_signature(payload): + logger.warning('Jupiter webhook: invalid signature') + return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST) + + try: + success = await _process_payment_service_callback( + payment_service, + payload, + 'process_jupiter_callback', + ) + if not success: + logger.error( + 'Jupiter webhook processing failed', + transaction_id=payload.get('transaction_id'), + ) + except Exception as e: + logger.exception('Jupiter webhook processing error', error=e) + # FPGate ожидает HTTP 200 как подтверждение приёма callback + return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK) + + routes_registered = True + + # Lava webhook (Lava Business) + if settings.is_lava_enabled(): + + @router.get(settings.LAVA_WEBHOOK_PATH) + async def lava_health() -> JSONResponse: + return JSONResponse( + { + 'status': 'ok', + 'service': 'lava_webhook', + 'enabled': settings.is_lava_enabled(), + } + ) + + @router.post(settings.LAVA_WEBHOOK_PATH) + async def lava_webhook(request: Request) -> JSONResponse: + try: + raw_body = await request.body() + payload = json.loads(raw_body) + except Exception as parse_error: + logger.error('Lava webhook: failed to parse JSON', parse_error=parse_error) + return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST) + + from app.services.lava_service import lava_service + + received_signature = (request.headers.get('Authorization') or '').strip() + if not lava_service.verify_webhook_signature(raw_body, received_signature): + logger.warning('Lava webhook: invalid signature') + return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST) + + try: + success = await _process_payment_service_callback( + payment_service, + payload, + 'process_lava_callback', + ) + if not success: + logger.error( + 'Lava webhook processing failed', + order_id=payload.get('order_id'), + invoice_id=payload.get('invoice_id'), + ) + except Exception as e: + logger.exception('Lava webhook processing error', error=e) + # Lava ожидает HTTP 200 как подтверждение приёма; иначе будет повтор до 5 раз раз в 150с + return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK) + + routes_registered = True + + # Donut webhook (Donut P2P) + if settings.is_donut_enabled(): + + @router.get(settings.DONUT_WEBHOOK_PATH) + async def donut_health() -> JSONResponse: + return JSONResponse( + { + 'status': 'ok', + 'service': 'donut_webhook', + 'enabled': settings.is_donut_enabled(), + } + ) + + @router.post(settings.DONUT_WEBHOOK_PATH) + async def donut_webhook(request: Request) -> JSONResponse: + try: + raw_body = await request.body() + payload = json.loads(raw_body) + except Exception as parse_error: + logger.error('Donut webhook: failed to parse JSON', parse_error=parse_error) + return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST) + + from app.services.donut_service import donut_service + + if not donut_service.verify_callback_signature(payload): + logger.warning('Donut webhook: invalid signature') + return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST) + + try: + success = await _process_payment_service_callback( + payment_service, + payload, + 'process_donut_callback', + ) + if not success: + logger.error( + 'Donut webhook processing failed', + transaction_id=payload.get('transaction_id'), + ) + except Exception as e: + logger.exception('Donut webhook processing error', error=e) + # Donut ожидает HTTP 200 как подтверждение приёма callback + return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK) + + routes_registered = True + if routes_registered: @router.get('/health/payment-webhooks') @@ -1493,6 +1728,11 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute 'rollypay_enabled': settings.is_rollypay_enabled(), 'overpay_enabled': settings.is_overpay_enabled(), 'aurapay_enabled': settings.is_aurapay_enabled(), + 'etoplatezhi_enabled': settings.is_etoplatezhi_enabled(), + 'antilopay_enabled': settings.is_antilopay_enabled(), + 'jupiter_enabled': settings.is_jupiter_enabled(), + 'donut_enabled': settings.is_donut_enabled(), + 'lava_enabled': settings.is_lava_enabled(), } ) diff --git a/migrations/alembic/versions/0068_add_apple_transactions.py b/migrations/alembic/versions/0068_add_apple_transactions.py new file mode 100644 index 00000000..540df824 --- /dev/null +++ b/migrations/alembic/versions/0068_add_apple_transactions.py @@ -0,0 +1,50 @@ +"""add apple_transactions table + +Revision ID: 0068 +Revises: 0067 +Create Date: 2026-04-11 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0068' +down_revision: Union[str, None] = '0067' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'apple_transactions', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('transaction_id', sa.String(64), unique=True, nullable=False), + sa.Column('original_transaction_id', sa.String(64), nullable=True), + sa.Column('product_id', sa.String(128), nullable=False), + sa.Column('bundle_id', sa.String(255), nullable=False), + sa.Column('amount_kopeks', sa.Integer(), nullable=False), + sa.Column('environment', sa.String(16), nullable=False), + sa.Column('status', sa.String(50), server_default='verified'), + sa.Column('is_paid', sa.Boolean(), server_default=sa.text('true')), + sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('refunded_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('transaction_id_fk', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True), + sa.Column('metadata_json', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_index('ix_apple_transactions_transaction_id', 'apple_transactions', ['transaction_id']) + op.create_index('ix_apple_transactions_original_transaction_id', 'apple_transactions', ['original_transaction_id']) + op.create_index('ix_apple_transactions_user_id', 'apple_transactions', ['user_id']) + + +def downgrade() -> None: + op.drop_index('ix_apple_transactions_user_id', table_name='apple_transactions') + op.drop_index('ix_apple_transactions_original_transaction_id', table_name='apple_transactions') + op.drop_index('ix_apple_transactions_transaction_id', table_name='apple_transactions') + op.drop_table('apple_transactions') diff --git a/migrations/alembic/versions/0069_create_etoplatezhi_payments.py b/migrations/alembic/versions/0069_create_etoplatezhi_payments.py new file mode 100644 index 00000000..529a51e0 --- /dev/null +++ b/migrations/alembic/versions/0069_create_etoplatezhi_payments.py @@ -0,0 +1,45 @@ +"""create etoplatezhi_payments table + +Revision ID: 0069 +Revises: 0068 +Create Date: 2026-05-04 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0069' +down_revision: Union[str, None] = '0068' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'etoplatezhi_payments', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True), + sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True), + sa.Column('etoplatezhi_payment_id', sa.String(128), unique=True, nullable=True, index=True), + sa.Column('amount_kopeks', sa.Integer(), nullable=False), + sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(32), nullable=False, server_default='pending'), + sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('payment_url', sa.Text(), nullable=True), + sa.Column('payment_method', sa.String(32), nullable=True), + sa.Column('metadata_json', sa.JSON(), nullable=True), + sa.Column('callback_payload', sa.JSON(), nullable=True), + sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table('etoplatezhi_payments') diff --git a/migrations/alembic/versions/0070_create_antilopay_payments.py b/migrations/alembic/versions/0070_create_antilopay_payments.py new file mode 100644 index 00000000..6b690203 --- /dev/null +++ b/migrations/alembic/versions/0070_create_antilopay_payments.py @@ -0,0 +1,45 @@ +"""create antilopay_payments table + +Revision ID: 0070 +Revises: 0069 +Create Date: 2026-05-04 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0070' +down_revision: Union[str, None] = '0069' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'antilopay_payments', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True), + sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True), + sa.Column('antilopay_payment_id', sa.String(128), unique=True, nullable=True, index=True), + sa.Column('amount_kopeks', sa.Integer(), nullable=False), + sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(32), nullable=False, server_default='pending'), + sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('payment_url', sa.Text(), nullable=True), + sa.Column('payment_method', sa.String(32), nullable=True), + sa.Column('metadata_json', sa.JSON(), nullable=True), + sa.Column('callback_payload', sa.JSON(), nullable=True), + sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table('antilopay_payments') diff --git a/migrations/alembic/versions/0071_add_last_revoke_at_to_subscriptions.py b/migrations/alembic/versions/0071_add_last_revoke_at_to_subscriptions.py new file mode 100644 index 00000000..df1f37ec --- /dev/null +++ b/migrations/alembic/versions/0071_add_last_revoke_at_to_subscriptions.py @@ -0,0 +1,25 @@ +"""add last_revoke_at to subscriptions + +Revision ID: 0071 +Revises: 0070 +Create Date: 2026-05-04 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0071' +down_revision: Union[str, None] = '0070' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('subscriptions', sa.Column('last_revoke_at', sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column('subscriptions', 'last_revoke_at') diff --git a/migrations/alembic/versions/0072_create_jupiter_payments.py b/migrations/alembic/versions/0072_create_jupiter_payments.py new file mode 100644 index 00000000..c49def3a --- /dev/null +++ b/migrations/alembic/versions/0072_create_jupiter_payments.py @@ -0,0 +1,45 @@ +"""create jupiter_payments table + +Revision ID: 0072 +Revises: 0071 +Create Date: 2026-05-04 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0072' +down_revision: Union[str, None] = '0071' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'jupiter_payments', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True), + sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True), + sa.Column('jupiter_transaction_id', sa.String(128), unique=True, nullable=True, index=True), + sa.Column('amount_kopeks', sa.Integer(), nullable=False), + sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(32), nullable=False, server_default='pending'), + sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('payment_url', sa.Text(), nullable=True), + sa.Column('payment_method', sa.String(32), nullable=True), + sa.Column('metadata_json', sa.JSON(), nullable=True), + sa.Column('callback_payload', sa.JSON(), nullable=True), + sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table('jupiter_payments') diff --git a/migrations/alembic/versions/0073_create_donut_payments.py b/migrations/alembic/versions/0073_create_donut_payments.py new file mode 100644 index 00000000..5d9854d9 --- /dev/null +++ b/migrations/alembic/versions/0073_create_donut_payments.py @@ -0,0 +1,45 @@ +"""create donut_payments table + +Revision ID: 0073 +Revises: 0072 +Create Date: 2026-05-04 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0073' +down_revision: Union[str, None] = '0072' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'donut_payments', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True), + sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True), + sa.Column('donut_transaction_id', sa.String(128), unique=True, nullable=True, index=True), + sa.Column('amount_kopeks', sa.Integer(), nullable=False), + sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(32), nullable=False, server_default='pending'), + sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('payment_url', sa.Text(), nullable=True), + sa.Column('payment_method', sa.String(32), nullable=True), + sa.Column('metadata_json', sa.JSON(), nullable=True), + sa.Column('callback_payload', sa.JSON(), nullable=True), + sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table('donut_payments') diff --git a/migrations/alembic/versions/0074_create_lava_payments.py b/migrations/alembic/versions/0074_create_lava_payments.py new file mode 100644 index 00000000..223c7d2c --- /dev/null +++ b/migrations/alembic/versions/0074_create_lava_payments.py @@ -0,0 +1,45 @@ +"""create lava_payments table + +Revision ID: 0074 +Revises: 0073 +Create Date: 2026-05-04 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0074' +down_revision: Union[str, None] = '0073' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'lava_payments', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True), + sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True), + sa.Column('lava_invoice_id', sa.String(128), unique=True, nullable=True, index=True), + sa.Column('amount_kopeks', sa.Integer(), nullable=False), + sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(32), nullable=False, server_default='pending'), + sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('payment_url', sa.Text(), nullable=True), + sa.Column('payment_method', sa.String(32), nullable=True), + sa.Column('metadata_json', sa.JSON(), nullable=True), + sa.Column('callback_payload', sa.JSON(), nullable=True), + sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table('lava_payments') diff --git a/pyproject.toml b/pyproject.toml index 4e5bb931..a9ae2549 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ 'rich>=14.0', 'aiohttp-socks>=0.10.1', 'httpx[socks]>=0.27.0', + 'pycryptodome>=3.21.0', ] [dependency-groups] diff --git a/tests/external/test_apple_iap.py b/tests/external/test_apple_iap.py new file mode 100644 index 00000000..24693941 --- /dev/null +++ b/tests/external/test_apple_iap.py @@ -0,0 +1,549 @@ +"""Tests for Apple In-App Purchase service and integration.""" + +from __future__ import annotations + +import base64 +import json +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pydantic import ValidationError + + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.config import settings +from app.external.apple_iap import AppleIAPService + + +@pytest.fixture +def anyio_backend() -> str: + return 'asyncio' + + +def _enable_apple_iap(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, 'APPLE_IAP_ENABLED', True, raising=False) + monkeypatch.setattr(settings, 'APPLE_IAP_KEY_ID', 'TEST_KEY_ID', raising=False) + monkeypatch.setattr(settings, 'APPLE_IAP_ISSUER_ID', 'test-issuer-id', raising=False) + monkeypatch.setattr(settings, 'APPLE_IAP_BUNDLE_ID', 'com.bitnet.vpnclient', raising=False) + monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Sandbox', raising=False) + # Use a dummy key -- we won't actually sign in tests + monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY', 'dummy-key', raising=False) + monkeypatch.setattr( + settings, + 'APPLE_IAP_PRODUCTS', + json.dumps( + { + 'com.bitnet.vpnclient.topup.100': 10_000, + 'com.bitnet.vpnclient.topup.300': 30_000, + 'com.bitnet.vpnclient.topup.500': 50_000, + 'com.bitnet.vpnclient.topup.1000': 100_000, + 'com.bitnet.vpnclient.topup.3000': 300_000, + } + ), + raising=False, + ) + + +# --------------------------------------------------------------------------- +# Product mapping +# --------------------------------------------------------------------------- + + +class TestProductMapping: + """Test product ID to kopeks mapping.""" + + def test_all_products_mapped(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + products = settings.get_apple_iap_products() + assert len(products) == 5 + + def test_product_100(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + products = settings.get_apple_iap_products() + assert products['com.bitnet.vpnclient.topup.100'] == 10_000 + + def test_product_300(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + products = settings.get_apple_iap_products() + assert products['com.bitnet.vpnclient.topup.300'] == 30_000 + + def test_product_500(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + products = settings.get_apple_iap_products() + assert products['com.bitnet.vpnclient.topup.500'] == 50_000 + + def test_product_1000(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + products = settings.get_apple_iap_products() + assert products['com.bitnet.vpnclient.topup.1000'] == 100_000 + + def test_product_3000(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + products = settings.get_apple_iap_products() + assert products['com.bitnet.vpnclient.topup.3000'] == 300_000 + + def test_unknown_product_not_in_map(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + products = settings.get_apple_iap_products() + assert 'com.bitnet.vpnclient.topup.999' not in products + + def test_invalid_json_returns_empty(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, 'APPLE_IAP_PRODUCTS', 'invalid-json', raising=False) + products = settings.get_apple_iap_products() + assert products == {} + + +# --------------------------------------------------------------------------- +# is_apple_iap_enabled() +# --------------------------------------------------------------------------- + + +class TestAppleIAPEnabled: + """Test is_apple_iap_enabled() helper.""" + + def test_enabled_with_all_params(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + assert settings.is_apple_iap_enabled() is True + + def test_disabled_when_flag_false(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + monkeypatch.setattr(settings, 'APPLE_IAP_ENABLED', False, raising=False) + assert settings.is_apple_iap_enabled() is False + + def test_disabled_when_key_id_missing(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + monkeypatch.setattr(settings, 'APPLE_IAP_KEY_ID', None, raising=False) + assert settings.is_apple_iap_enabled() is False + + def test_disabled_when_issuer_id_missing(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + monkeypatch.setattr(settings, 'APPLE_IAP_ISSUER_ID', None, raising=False) + assert settings.is_apple_iap_enabled() is False + + def test_disabled_when_no_private_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY', None, raising=False) + monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY_PATH', None, raising=False) + assert settings.is_apple_iap_enabled() is False + + def test_enabled_with_key_path_only(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY', None, raising=False) + monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY_PATH', '/tmp/test.p8', raising=False) # noqa: S108 + assert settings.is_apple_iap_enabled() is True + + +# --------------------------------------------------------------------------- +# validate_transaction_info +# --------------------------------------------------------------------------- + + +class TestTransactionValidation: + """Test validate_transaction_info.""" + + def test_valid_transaction(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + txn_info = { + 'bundleId': 'com.bitnet.vpnclient', + 'productId': 'com.bitnet.vpnclient.topup.100', + 'type': 'Consumable', + } + result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100') + assert result is None # None means valid + + def test_wrong_bundle_id(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + txn_info = { + 'bundleId': 'com.other.app', + 'productId': 'com.bitnet.vpnclient.topup.100', + 'type': 'Consumable', + } + result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100') + assert result is not None + assert 'Bundle ID' in result + + def test_wrong_product_id(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + txn_info = { + 'bundleId': 'com.bitnet.vpnclient', + 'productId': 'com.bitnet.vpnclient.topup.500', + 'type': 'Consumable', + } + result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100') + assert result is not None + assert 'Product ID' in result + + def test_wrong_type(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + txn_info = { + 'bundleId': 'com.bitnet.vpnclient', + 'productId': 'com.bitnet.vpnclient.topup.100', + 'type': 'Auto-Renewable Subscription', + } + result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100') + assert result is not None + assert 'type' in result + + def test_revoked_transaction(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + txn_info = { + 'bundleId': 'com.bitnet.vpnclient', + 'productId': 'com.bitnet.vpnclient.topup.100', + 'type': 'Consumable', + 'revocationDate': 1700000000000, + } + result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100') + assert result is not None + assert 'revoked' in result.lower() + + def test_valid_without_revocation(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A transaction without revocationDate should be valid.""" + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + txn_info = { + 'bundleId': 'com.bitnet.vpnclient', + 'productId': 'com.bitnet.vpnclient.topup.100', + 'type': 'Consumable', + } + result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100') + assert result is None + + +# --------------------------------------------------------------------------- +# Environment URL selection +# --------------------------------------------------------------------------- + + +class TestBaseUrl: + """Test environment URL selection.""" + + def test_production_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + url = service._get_base_url('Production') + assert 'api.storekit.itunes.apple.com' in url + + def test_sandbox_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + url = service._get_base_url('Sandbox') + assert 'api.storekit-sandbox.itunes.apple.com' in url + + def test_default_uses_config(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + url = service._get_base_url() + assert 'sandbox' in url # Fixture sets Sandbox + + +# --------------------------------------------------------------------------- +# _decode_jws_payload (raw decode, no verification) +# --------------------------------------------------------------------------- + + +class TestJWSPayloadDecoding: + """Test _decode_jws_payload.""" + + def test_decode_valid_jws(self) -> None: + service = AppleIAPService() + header = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b'=').decode() + payload_data = {'bundleId': 'com.test', 'productId': 'test.product'} + payload = base64.urlsafe_b64encode(json.dumps(payload_data).encode()).rstrip(b'=').decode() + signature = base64.urlsafe_b64encode(b'fake-signature').rstrip(b'=').decode() + jws = f'{header}.{payload}.{signature}' + + result = service._decode_jws_payload(jws) + assert result is not None + assert result['bundleId'] == 'com.test' + assert result['productId'] == 'test.product' + + def test_decode_invalid_jws(self) -> None: + service = AppleIAPService() + result = service._decode_jws_payload('not-a-jws') + assert result is None + + def test_decode_empty_string(self) -> None: + service = AppleIAPService() + result = service._decode_jws_payload('') + assert result is None + + +# --------------------------------------------------------------------------- +# _verify_and_decode_jws (x5c + ES256 verification) +# --------------------------------------------------------------------------- + + +class TestVerifyAndDecodeJWS: + """Test _verify_and_decode_jws -- the full x5c chain + signature path.""" + + def test_rejects_bad_format(self) -> None: + service = AppleIAPService() + assert service._verify_and_decode_jws('only-two.parts') is None + + def test_rejects_missing_x5c(self) -> None: + service = AppleIAPService() + # Valid 3-part JWS but header has no x5c + header = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b'=').decode() + payload = base64.urlsafe_b64encode(b'{}').rstrip(b'=').decode() + sig = base64.urlsafe_b64encode(b'sig').rstrip(b'=').decode() + assert service._verify_and_decode_jws(f'{header}.{payload}.{sig}') is None + + def test_rejects_empty_x5c(self) -> None: + service = AppleIAPService() + header = base64.urlsafe_b64encode(json.dumps({'alg': 'ES256', 'x5c': []}).encode()).rstrip(b'=').decode() + payload = base64.urlsafe_b64encode(b'{}').rstrip(b'=').decode() + sig = base64.urlsafe_b64encode(b'sig').rstrip(b'=').decode() + assert service._verify_and_decode_jws(f'{header}.{payload}.{sig}') is None + + def test_verify_notification_delegates(self) -> None: + """verify_notification should delegate to _verify_and_decode_jws.""" + service = AppleIAPService() + service._verify_and_decode_jws = MagicMock(return_value={'test': True}) + result = service.verify_notification('signed.payload.jws') + service._verify_and_decode_jws.assert_called_once_with('signed.payload.jws') + assert result == {'test': True} + + +# --------------------------------------------------------------------------- +# verify_transaction with mocked HTTP +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio('asyncio') +class TestVerifyTransaction: + """Test verify_transaction with mocked _fetch_transaction.""" + + @staticmethod + def _ok_response(signed_info: str = 'header.payload.sig') -> MagicMock: + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {'signedTransactionInfo': signed_info} + return resp + + @staticmethod + def _error_response(status: int, text: str = '') -> MagicMock: + resp = MagicMock() + resp.status_code = status + resp.text = text + return resp + + async def test_successful_verification(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + + txn_data = { + 'bundleId': 'com.bitnet.vpnclient', + 'productId': 'com.bitnet.vpnclient.topup.100', + 'type': 'Consumable', + 'transactionId': '2000000123456789', + 'environment': 'Sandbox', + } + + monkeypatch.setattr(service, '_fetch_transaction', AsyncMock(return_value=self._ok_response())) + monkeypatch.setattr(service, '_verify_and_decode_jws', lambda token: txn_data) + + result = await service.verify_transaction('2000000123456789', 'Sandbox') + + assert result is not None + assert result['bundleId'] == 'com.bitnet.vpnclient' + assert result['transactionId'] == '2000000123456789' + + async def test_verification_with_jws_failure(self, monkeypatch: pytest.MonkeyPatch) -> None: + """If _verify_and_decode_jws returns None, verify_transaction returns None.""" + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + + monkeypatch.setattr(service, '_fetch_transaction', AsyncMock(return_value=self._ok_response())) + monkeypatch.setattr(service, '_verify_and_decode_jws', lambda token: None) + + result = await service.verify_transaction('2000000123456789', 'Sandbox') + assert result is None + + async def test_transaction_not_found_both_envs(self, monkeypatch: pytest.MonkeyPatch) -> None: + """404 on primary triggers fallback; 404 on fallback returns None.""" + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + + fetch_mock = AsyncMock(return_value=self._error_response(404)) + monkeypatch.setattr(service, '_fetch_transaction', fetch_mock) + + result = await service.verify_transaction('nonexistent', 'Sandbox') + assert result is None + # Should have been called twice (primary + fallback) + assert fetch_mock.call_count == 2 + + async def test_fallback_succeeds_on_404(self, monkeypatch: pytest.MonkeyPatch) -> None: + """404 on primary, 200 on fallback -- should succeed.""" + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + + txn_data = {'bundleId': 'com.bitnet.vpnclient', 'type': 'Consumable'} + responses = [self._error_response(404), self._ok_response()] + fetch_mock = AsyncMock(side_effect=responses) + monkeypatch.setattr(service, '_fetch_transaction', fetch_mock) + monkeypatch.setattr(service, '_verify_and_decode_jws', lambda token: txn_data) + + result = await service.verify_transaction('12345', 'Production') + assert result is not None + assert fetch_mock.call_count == 2 + + async def test_network_error_no_retry(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Network error (None response) should not retry.""" + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + + fetch_mock = AsyncMock(return_value=None) + monkeypatch.setattr(service, '_fetch_transaction', fetch_mock) + + result = await service.verify_transaction('12345', 'Sandbox') + assert result is None + assert fetch_mock.call_count == 1 # no fallback on network error + + async def test_5xx_no_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + """5xx errors should not trigger fallback (only 4xx does).""" + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + + fetch_mock = AsyncMock(return_value=self._error_response(500, 'Internal')) + monkeypatch.setattr(service, '_fetch_transaction', fetch_mock) + + result = await service.verify_transaction('12345', 'Sandbox') + assert result is None + assert fetch_mock.call_count == 1 + + async def test_rate_limit_triggers_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + """429 is 4xx -> triggers fallback.""" + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + + fetch_mock = AsyncMock(return_value=self._error_response(429)) + monkeypatch.setattr(service, '_fetch_transaction', fetch_mock) + + result = await service.verify_transaction('123', 'Sandbox') + assert result is None + assert fetch_mock.call_count == 2 # primary + fallback + + async def test_no_signed_transaction_info(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {} # missing signedTransactionInfo + monkeypatch.setattr(service, '_fetch_transaction', AsyncMock(return_value=resp)) + + result = await service.verify_transaction('123', 'Sandbox') + assert result is None + + +# --------------------------------------------------------------------------- +# Schema validation +# --------------------------------------------------------------------------- + + +class TestApplePurchaseRequestSchema: + """Test ApplePurchaseRequest pydantic validation.""" + + def test_valid_request(self) -> None: + from app.cabinet.schemas.apple_iap import ApplePurchaseRequest + + req = ApplePurchaseRequest( + product_id='com.bitnet.vpnclient.topup.100', + transaction_id='2000000123456789', + ) + assert req.transaction_id == '2000000123456789' + + def test_rejects_non_numeric_transaction_id(self) -> None: + from app.cabinet.schemas.apple_iap import ApplePurchaseRequest + + with pytest.raises(Exception, match='digits'): + ApplePurchaseRequest( + product_id='com.bitnet.vpnclient.topup.100', + transaction_id='abc-not-numeric', + ) + + def test_rejects_empty_transaction_id(self) -> None: + from app.cabinet.schemas.apple_iap import ApplePurchaseRequest + + with pytest.raises(ValidationError): + ApplePurchaseRequest( + product_id='com.bitnet.vpnclient.topup.100', + transaction_id='', + ) + + def test_rejects_too_long_transaction_id(self) -> None: + from app.cabinet.schemas.apple_iap import ApplePurchaseRequest + + with pytest.raises(ValidationError): + ApplePurchaseRequest( + product_id='com.bitnet.vpnclient.topup.100', + transaction_id='1' * 65, + ) + + def test_no_environment_field(self) -> None: + """Schema should not accept environment -- it's server-side only.""" + from app.cabinet.schemas.apple_iap import ApplePurchaseRequest + + req = ApplePurchaseRequest( + product_id='com.bitnet.vpnclient.topup.100', + transaction_id='123', + ) + assert not hasattr(req, 'environment') + + +# --------------------------------------------------------------------------- +# Sandbox detection +# --------------------------------------------------------------------------- + + +class TestSandboxDetection: + """Test that sandbox transactions don't credit real balance.""" + + def test_sandbox_env_detected_from_txn_info(self, monkeypatch: pytest.MonkeyPatch) -> None: + """validate_transaction_info does not reject sandbox env -- that's handled at the route level.""" + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + txn_info = { + 'bundleId': 'com.bitnet.vpnclient', + 'productId': 'com.bitnet.vpnclient.topup.100', + 'type': 'Consumable', + 'environment': 'Sandbox', + } + result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100') + assert result is None # validation passes -- sandbox check is higher up + + def test_production_txn_on_production_passes(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Production environment in txn_info + Production config = proceed normally.""" + _enable_apple_iap(monkeypatch) + monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Production', raising=False) + txn_info = {'environment': 'Production'} + is_sandbox = txn_info.get('environment') == 'Sandbox' + assert is_sandbox is False + + def test_sandbox_txn_on_production_detected(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Sandbox environment in txn_info + Production config = sandbox detected.""" + _enable_apple_iap(monkeypatch) + monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Production', raising=False) + txn_info = {'environment': 'Sandbox'} + is_sandbox = txn_info.get('environment') == 'Sandbox' + should_skip_balance = is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production' + assert should_skip_balance is True + + def test_sandbox_txn_on_sandbox_credits_normally(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Sandbox environment in txn_info + Sandbox config = credit normally (testing).""" + _enable_apple_iap(monkeypatch) + monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Sandbox', raising=False) + txn_info = {'environment': 'Sandbox'} + is_sandbox = txn_info.get('environment') == 'Sandbox' + should_skip_balance = is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production' + assert should_skip_balance is False diff --git a/uv.lock b/uv.lock index af8e681f..80d5dddc 100644 --- a/uv.lock +++ b/uv.lock @@ -883,6 +883,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + [[package]] name = "pycryptodomex" version = "3.23.0" @@ -1155,6 +1185,7 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "httpx", extra = ["socks"] }, { name = "packaging" }, + { name = "pycryptodome" }, { name = "pyjwt" }, { name = "python-dateutil" }, { name = "pyyaml" }, @@ -1187,6 +1218,7 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.129.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.27.0" }, { name = "packaging", specifier = ">=26.0" }, + { name = "pycryptodome", specifier = ">=3.21.0" }, { name = "pyjwt", specifier = ">=2.11.0" }, { name = "python-dateutil", specifier = ">=2.9.0.post0" }, { name = "pyyaml", specifier = ">=6.0.3" },