From 2385814d77c6b847fb0df248b5aaaa2ff27fd20e Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 05:04:27 +0300 Subject: [PATCH 01/15] =?UTF-8?q?fix:=20guide=20mode=20buttons=20=E2=80=94?= =?UTF-8?q?=20support=20external=20type=20alias,=20extract=20urlScheme=20f?= =?UTF-8?q?rom=20blocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From PR #2923 by @dotX12, with improvements: - Support type: "external" as alias for "externalLink" in app config - Extract urlScheme from subscriptionLink buttons in blocks[] when not at root - Wrap custom URL schemes in HTTPS redirect for Telegram compatibility - Fallback to plain subscription URL when no redirect template configured Improvements over original PR: - Also check btn.get('url') not just btn.get('link') for scheme extraction - Validate extracted scheme contains :// before accepting - Skip redundant redirect wrapping when create_deep_link already wrapped --- app/handlers/subscription/common.py | 28 +++++++++++++++++++++- app/keyboards/inline.py | 36 +++++++++++++++++++++++------ 2 files changed, 56 insertions(+), 8 deletions(-) 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/keyboards/inline.py b/app/keyboards/inline.py index 5b6dc0d9..7b9a676b 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 @@ -2554,9 +2560,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 +2612,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 +2624,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 +2649,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( [ From 2478ff7c3d0c426c5f64dec71d17df13de60702a Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 05:14:47 +0300 Subject: [PATCH 02/15] =?UTF-8?q?fix:=20expired=5F1d=20notification=20?= =?UTF-8?q?=E2=80=94=20use=20PricingEngine=20instead=20of=20hardcoded=20PR?= =?UTF-8?q?ICE=5F30=5FDAYS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/handlers/admin/monitoring.py | 3 +++ app/services/monitoring_service.py | 27 ++++++++++++++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) 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/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, ) From 99648a956e20cb99d406455db2f26db469ba7235 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 05:27:49 +0300 Subject: [PATCH 03/15] =?UTF-8?q?fix:=20persist=20campaign=20across=20bot?= =?UTF-8?q?=E2=86=92webapp=20registration=20handoff=20via=20Redis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/cabinet/routes/auth.py | 162 +++++++++++++++++++------------ app/handlers/start.py | 48 ++++++++- app/services/referral_service.py | 67 +++++++++++++ 3 files changed, 216 insertions(+), 61 deletions(-) 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/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/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: From ecde2fb8f05620889f45fea4410e9069b99340a5 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 05:49:38 +0300 Subject: [PATCH 04/15] feat: Apple IAP integration with security hardening --- app/cabinet/routes/__init__.py | 12 + app/cabinet/routes/apple_iap.py | 267 +++++++++ app/cabinet/schemas/apple_iap.py | 23 + app/config.py | 41 ++ app/database/crud/apple_iap.py | 83 +++ app/database/models.py | 36 ++ app/external/apple_iap.py | 411 +++++++++++++ app/external/webhook_server.py | 338 +++++++++++ app/services/backup_service.py | 3 + .../versions/0068_add_apple_transactions.py | 50 ++ tests/external/test_apple_iap.py | 548 ++++++++++++++++++ 11 files changed, 1812 insertions(+) create mode 100644 app/cabinet/routes/apple_iap.py create mode 100644 app/cabinet/schemas/apple_iap.py create mode 100644 app/database/crud/apple_iap.py create mode 100644 app/external/apple_iap.py create mode 100644 migrations/alembic/versions/0068_add_apple_transactions.py create mode 100644 tests/external/test_apple_iap.py 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/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..db025767 100644 --- a/app/config.py +++ b/app/config.py @@ -616,6 +616,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 @@ -2027,6 +2040,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 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/models.py b/app/database/models.py index 8e710ceb..22df6d4b 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -162,6 +162,7 @@ class PaymentMethod(Enum): KASSA_AI = 'kassa_ai' RIOPAY = 'riopay' SEVERPAY = 'severpay' + APPLE_IAP = 'apple_iap' PAYPEAR = 'paypear' ROLLYPAY = 'rollypay' OVERPAY = 'overpay' @@ -329,6 +330,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' 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..969168db 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,334 @@ 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 ( + get_apple_transaction_by_transaction_id, + 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: + apple_txn = await get_apple_transaction_by_transaction_id(db, lookup_id) + if not apple_txn: + # Try the other ID + apple_txn = await get_apple_transaction_by_transaction_id(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/services/backup_service.py b/app/services/backup_service.py index 2d3759d1..5a2a0d29 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,7 @@ class BackupService: 'rollypay_payments', 'overpay_payments', 'aurapay_payments', + 'apple_transactions', 'saved_payment_methods', # --- Content/config --- 'pinned_messages', 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/tests/external/test_apple_iap.py b/tests/external/test_apple_iap.py new file mode 100644 index 00000000..83610c1d --- /dev/null +++ b/tests/external/test_apple_iap.py @@ -0,0 +1,548 @@ +"""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, patch + +import pytest + + +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) + 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(Exception): + 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(Exception): + 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 From e85c40f8cdca1e3392da06f6539f0043f923b731 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 05:54:11 +0300 Subject: [PATCH 05/15] =?UTF-8?q?fix:=20apple=20refund=20handler=20?= =?UTF-8?q?=E2=80=94=20lock=20apple=5Ftransactions=20row=20to=20prevent=20?= =?UTF-8?q?double=20deduction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/external/webhook_server.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py index 969168db..d9446456 100644 --- a/app/external/webhook_server.py +++ b/app/external/webhook_server.py @@ -601,7 +601,6 @@ class WebhookServer: product_id = txn_info.get('productId', '') from app.database.crud.apple_iap import ( - get_apple_transaction_by_transaction_id, mark_apple_transaction_refunded, ) from app.database.crud.user import lock_user_for_pricing @@ -611,10 +610,12 @@ class WebhookServer: lookup_id = original_txn_id or apple_txn_id async with AsyncSessionLocal() as db: - apple_txn = await get_apple_transaction_by_transaction_id(db, lookup_id) + 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(db, apple_txn_id) + apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, apple_txn_id) if not apple_txn: logger.warning( From 17ac3da3c4da0f36015e338451a22a1a7a433524 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 06:44:03 +0300 Subject: [PATCH 06/15] fix: AuraPay webhook signature + add SBP/Card payment method selection - Fix webhook signature: str(None) produced "None" (4 chars) instead of "" like PHP implode() does, causing all webhooks with custom_fields=null to fail signature verification - Add AURAPAY_SBP_ENABLED / AURAPAY_CARD_ENABLED env vars with display names, following Freekassa pattern for sub-method selection - Add aurapay_sbp / aurapay_card buttons in payment keyboard - Add start_aurapay_sbp_topup / start_aurapay_card_topup handlers - Route dispatch handles aurapay / aurapay_sbp / aurapay_card - payment_utils updated with SBP/Card availability checks - service parameter ("sbp"/"card") now passed through to AuraPay API --- app/config.py | 24 +++++++++++ app/handlers/balance/aurapay.py | 71 +++++++++++++++++++++++++++++---- app/handlers/balance/main.py | 6 ++- app/keyboards/inline.py | 30 +++++++++++++- app/services/aurapay_service.py | 5 ++- app/utils/payment_utils.py | 36 ++++++++++++++++- 6 files changed, 159 insertions(+), 13 deletions(-) diff --git a/app/config.py b/app/config.py index db025767..f589ed6c 100644 --- a/app/config.py +++ b/app/config.py @@ -681,6 +681,10 @@ 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)' MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet' # Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции) @@ -2118,6 +2122,26 @@ 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_kassa_ai_sbp_enabled(self) -> bool: return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled() 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/main.py b/app/handlers/balance/main.py index 98a365c6..f931be07 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -170,7 +170,7 @@ 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: @@ -768,9 +768,11 @@ 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 .mulenpay import check_mulenpay_payment_status diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 7b9a676b..040cc154 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1850,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( [ diff --git a/app/services/aurapay_service.py b/app/services/aurapay_service.py index 2dde0457..372c887c 100644 --- a/app/services/aurapay_service.py +++ b/app/services/aurapay_service.py @@ -197,8 +197,11 @@ 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/utils/payment_utils.py b/app/utils/payment_utils.py index 5cabdcac..1b2e25ca 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( { @@ -381,6 +409,10 @@ 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 == 'support': return settings.is_support_topup_enabled() return False @@ -409,6 +441,8 @@ 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(), 'support': settings.is_support_topup_enabled(), } From 6524f66da2717001aba99831f8dec93ad8f6e6f8 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 07:17:54 +0300 Subject: [PATCH 07/15] feat: integrate Etoplatezhi payment provider - Add etoplatezhi_service.py with HMAC-SHA512+base64 signature algorithm, payment URL builder, and callback signature verification - Add payment mixin with create/process/finalize flow, 12 status mappings - Add CRUD operations with FOR UPDATE locking, idempotency checks - Add Telegram handlers with SBP/Card sub-method selection - Add Alembic migration for etoplatezhi_payments table - Add config settings (ETOPLATEZHI_ENABLED, PROJECT_ID, SECRET_KEY, SBP_ENABLED, CARD_ENABLED, display names, min/max amounts) - Add webhook endpoint with JSON-body signature verification - Register in payment keyboard, router, utils, backup, method config --- app/config.py | 50 ++ app/database/crud/etoplatezhi.py | 161 ++++++ app/database/crud/transaction.py | 1 + app/database/models.py | 63 +++ app/handlers/balance/etoplatezhi.py | 302 ++++++++++ app/handlers/balance/main.py | 13 + app/keyboards/inline.py | 40 ++ app/services/backup_service.py | 1 + app/services/etoplatezhi_service.py | 202 +++++++ app/services/payment/etoplatezhi.py | 518 ++++++++++++++++++ app/services/payment_method_config_service.py | 11 + app/services/payment_service.py | 59 ++ app/utils/payment_utils.py | 51 ++ app/webserver/payments.py | 48 ++ .../0069_create_etoplatezhi_payments.py | 45 ++ 15 files changed, 1565 insertions(+) create mode 100644 app/database/crud/etoplatezhi.py create mode 100644 app/handlers/balance/etoplatezhi.py create mode 100644 app/services/etoplatezhi_service.py create mode 100644 app/services/payment/etoplatezhi.py create mode 100644 migrations/alembic/versions/0069_create_etoplatezhi_payments.py diff --git a/app/config.py b/app/config.py index f589ed6c..82cb061b 100644 --- a/app/config.py +++ b/app/config.py @@ -686,6 +686,22 @@ class Settings(BaseSettings): AURAPAY_CARD_ENABLED: bool = False AURAPAY_CARD_DISPLAY_NAME: str = 'Карта (AuraPay)' + # 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 (красный), '' (по умолчанию для каждой секции) CABINET_BUTTON_STYLE: str = '' @@ -2142,6 +2158,40 @@ class Settings(BaseSettings): def get_aurapay_card_display_name_html(self) -> str: return html.escape(self.get_aurapay_card_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/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/transaction.py b/app/database/crud/transaction.py index a2d6f490..ab30faa6 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -31,6 +31,7 @@ REAL_PAYMENT_METHODS = [ PaymentMethod.PAYPEAR.value, PaymentMethod.OVERPAY.value, PaymentMethod.AURAPAY.value, + PaymentMethod.ETOPLATEZHI.value, ] diff --git a/app/database/models.py b/app/database/models.py index 22df6d4b..fd2bc305 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -167,6 +167,7 @@ class PaymentMethod(Enum): ROLLYPAY = 'rollypay' OVERPAY = 'overpay' AURAPAY = 'aurapay' + ETOPLATEZHI = 'etoplatezhi' MANUAL = 'manual' BALANCE = 'balance' @@ -1166,6 +1167,68 @@ 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 PromoGroup(Base): __tablename__ = 'promo_groups' 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/main.py b/app/handlers/balance/main.py index f931be07..0242d23b 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -177,6 +177,13 @@ async def route_payment_by_method( 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 == 'riopay': from .riopay import process_riopay_payment_amount @@ -774,6 +781,12 @@ def register_balance_handlers(dp: Dispatcher): 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 .mulenpay import check_mulenpay_payment_status dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_')) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 040cc154..6d5f471e 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1890,6 +1890,46 @@ 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_support_topup_enabled(): keyboard.append( [ diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 5a2a0d29..ddff2db4 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -1517,6 +1517,7 @@ class BackupService: 'rollypay_payments', 'overpay_payments', 'aurapay_payments', + 'etoplatezhi_payments', 'apple_transactions', 'saved_payment_methods', # --- Content/config --- 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/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_method_config_service.py b/app/services/payment_method_config_service.py index 35f21da6..9e213284 100644 --- a/app/services/payment_method_config_service.py +++ b/app/services/payment_method_config_service.py @@ -189,6 +189,16 @@ 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': 'СБП'}, + ], + }, } @@ -235,6 +245,7 @@ DEFAULT_METHOD_ORDER = [ 'rollypay', 'overpay', 'aurapay', + 'etoplatezhi', ] diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 47e09270..11f4c556 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -32,6 +32,7 @@ from app.services.payment import ( ) from app.services.payment.aurapay import AuraPayPaymentMixin from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin +from app.services.payment.etoplatezhi import EtoplatezhiPaymentMixin from app.services.payment.freekassa import FreekassaPaymentMixin from app.services.payment.kassa_ai import KassaAiPaymentMixin from app.services.payment.overpay import OverpayPaymentMixin @@ -482,6 +483,41 @@ 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) + + # 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 +545,7 @@ class PaymentService( RollyPayPaymentMixin, OverpayPaymentMixin, AuraPayPaymentMixin, + EtoplatezhiPaymentMixin, ): """Основной интерфейс платежей, делегирующий работу специализированным mixin-ам.""" @@ -1016,6 +1053,28 @@ 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 + # --- Telegram Stars --------------------------------------------------- if payment_method == 'telegram_stars': if not settings.TELEGRAM_STARS_ENABLED: diff --git a/app/utils/payment_utils.py b/app/utils/payment_utils.py index 1b2e25ca..03d1e128 100644 --- a/app/utils/payment_utils.py +++ b/app/utils/payment_utils.py @@ -272,6 +272,46 @@ 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_support_topup_enabled(): methods.append( { @@ -413,6 +453,12 @@ def is_payment_method_available(method_id: str) -> bool: 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 == 'support': return settings.is_support_topup_enabled() return False @@ -443,6 +489,9 @@ def get_payment_method_status() -> dict[str, bool]: '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(), 'support': settings.is_support_topup_enabled(), } @@ -488,4 +537,6 @@ def get_enabled_payment_methods_count() -> int: count += 1 if settings.is_aurapay_enabled(): count += 1 + if settings.is_etoplatezhi_enabled(): + count += 1 return count diff --git a/app/webserver/payments.py b/app/webserver/payments.py index b4e158d0..c0aa4651 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -1469,6 +1469,53 @@ 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 + if routes_registered: @router.get('/health/payment-webhooks') @@ -1493,6 +1540,7 @@ 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(), } ) 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') From 719664208e94fe1e528ff0f85f500e5b96555dc9 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 07:44:17 +0300 Subject: [PATCH 08/15] feat: integrate Antilopay payment provider (API v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add antilopay_service.py with SHA256WithRSA signing (pycryptodome), private key for requests, public key for callback verification - Add payment mixin with create/callback/finalize/check_status flows, kopeks↔rubles conversion, 7 status mappings, prefer_methods support - Add CRUD with FOR UPDATE locking, idempotency checks - Add handlers with SBP/Card/SberPay sub-method selection - Add Alembic migration for antilopay_payments table - Add config (ANTILOPAY_ENABLED, SECRET_ID, PRIVATE_KEY, PUBLIC_KEY, PROJECT_ID, SBP/CARD/SBERPAY enabled/display names) - Add webhook endpoint with X-Apay-Callback header signature verification - Register in keyboard, router, utils, backup, method config --- app/config.py | 68 +++ app/database/crud/antilopay.py | 161 ++++++ app/database/crud/transaction.py | 1 + app/database/models.py | 63 +++ app/handlers/balance/antilopay.py | 315 +++++++++++ app/handlers/balance/main.py | 19 + app/keyboards/inline.py | 53 ++ app/services/antilopay_service.py | 246 ++++++++ app/services/backup_service.py | 1 + app/services/payment/antilopay.py | 533 ++++++++++++++++++ app/services/payment_method_config_service.py | 12 + app/services/payment_service.py | 59 ++ app/utils/payment_utils.py | 67 +++ app/webserver/payments.py | 49 ++ .../0070_create_antilopay_payments.py | 45 ++ 15 files changed, 1692 insertions(+) create mode 100644 app/database/crud/antilopay.py create mode 100644 app/handlers/balance/antilopay.py create mode 100644 app/services/antilopay_service.py create mode 100644 app/services/payment/antilopay.py create mode 100644 migrations/alembic/versions/0070_create_antilopay_payments.py diff --git a/app/config.py b/app/config.py index 82cb061b..9cd6fa5b 100644 --- a/app/config.py +++ b/app/config.py @@ -686,6 +686,28 @@ class Settings(BaseSettings): 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)' + # Etoplatezhi (paymentpage.etoplatezhi.ru) ETOPLATEZHI_ENABLED: bool = False ETOPLATEZHI_PROJECT_ID: int | None = None @@ -2158,6 +2180,52 @@ class Settings(BaseSettings): 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_etoplatezhi_enabled(self) -> bool: return ( self.ETOPLATEZHI_ENABLED diff --git a/app/database/crud/antilopay.py b/app/database/crud/antilopay.py new file mode 100644 index 00000000..37e6e689 --- /dev/null +++ b/app/database/crud/antilopay.py @@ -0,0 +1,161 @@ +"""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/transaction.py b/app/database/crud/transaction.py index ab30faa6..2b1a4821 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -32,6 +32,7 @@ REAL_PAYMENT_METHODS = [ 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 fd2bc305..fa6fc78b 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -168,6 +168,7 @@ class PaymentMethod(Enum): OVERPAY = 'overpay' AURAPAY = 'aurapay' ETOPLATEZHI = 'etoplatezhi' + ANTILOPAY = 'antilopay' MANUAL = 'manual' BALANCE = 'balance' @@ -1229,6 +1230,68 @@ class EtoplatezhiPayment(Base): 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 PromoGroup(Base): __tablename__ = 'promo_groups' 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/main.py b/app/handlers/balance/main.py index 0242d23b..2047371d 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -184,6 +184,13 @@ async def route_payment_by_method( 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 == 'riopay': from .riopay import process_riopay_payment_amount @@ -787,6 +794,18 @@ def register_balance_handlers(dp: Dispatcher): 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 .mulenpay import check_mulenpay_payment_status dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_')) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 6d5f471e..98986af7 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1930,6 +1930,59 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN ) 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_support_topup_enabled(): keyboard.append( [ 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/backup_service.py b/app/services/backup_service.py index ddff2db4..28ec6bcf 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -1518,6 +1518,7 @@ class BackupService: 'overpay_payments', 'aurapay_payments', 'etoplatezhi_payments', + 'antilopay_payments', 'apple_transactions', 'saved_payment_methods', # --- Content/config --- diff --git a/app/services/payment/antilopay.py b/app/services/payment/antilopay.py new file mode 100644 index 00000000..a76385b4 --- /dev/null +++ b/app/services/payment/antilopay.py @@ -0,0 +1,533 @@ +"""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_method_config_service.py b/app/services/payment_method_config_service.py index 9e213284..ab583bd4 100644 --- a/app/services/payment_method_config_service.py +++ b/app/services/payment_method_config_service.py @@ -199,6 +199,17 @@ def _get_method_defaults() -> dict: {'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'}, + ], + }, } @@ -246,6 +257,7 @@ DEFAULT_METHOD_ORDER = [ 'overpay', 'aurapay', 'etoplatezhi', + 'antilopay', ] diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 11f4c556..55955910 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -30,6 +30,7 @@ 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.etoplatezhi import EtoplatezhiPaymentMixin @@ -518,6 +519,41 @@ async def link_etoplatezhi_payment_to_transaction(*args, **kwargs): 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) + + # 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] = { @@ -546,6 +582,7 @@ class PaymentService( OverpayPaymentMixin, AuraPayPaymentMixin, EtoplatezhiPaymentMixin, + AntilopayPaymentMixin, ): """Основной интерфейс платежей, делегирующий работу специализированным mixin-ам.""" @@ -1075,6 +1112,28 @@ class PaymentService( } 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 + # --- Telegram Stars --------------------------------------------------- if payment_method == 'telegram_stars': if not settings.TELEGRAM_STARS_ENABLED: diff --git a/app/utils/payment_utils.py b/app/utils/payment_utils.py index 03d1e128..973fe118 100644 --- a/app/utils/payment_utils.py +++ b/app/utils/payment_utils.py @@ -312,6 +312,59 @@ def get_available_payment_methods() -> list[dict[str, str]]: } ) + 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( { @@ -459,6 +512,14 @@ def is_payment_method_available(method_id: str) -> bool: 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 @@ -492,6 +553,10 @@ def get_payment_method_status() -> dict[str, bool]: '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(), } @@ -539,4 +604,6 @@ def get_enabled_payment_methods_count() -> int: 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 c0aa4651..4112a0e8 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -1516,6 +1516,54 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute 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 + if routes_registered: @router.get('/health/payment-webhooks') @@ -1541,6 +1589,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute 'overpay_enabled': settings.is_overpay_enabled(), 'aurapay_enabled': settings.is_aurapay_enabled(), 'etoplatezhi_enabled': settings.is_etoplatezhi_enabled(), + 'antilopay_enabled': settings.is_antilopay_enabled(), } ) 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') From 1ab1ff90bf6fc243da45a53b2d549464e47c2f31 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 08:08:56 +0300 Subject: [PATCH 09/15] feat: add subscription reissue with 15-min cooldown - Add revoke handler for classic and multi-tariff modes with 2-step confirmation dialog and TOCTOU-safe cooldown enforcement - Add cabinet API endpoint POST /subscription/revoke with 429 + Retry-After for cooldown, IDOR protection via resolve_subscription - Add last_revoke_at column to subscriptions (Alembic migration 0071) - Add SUBSCRIPTION_REVOKE_ENABLED and COOLDOWN_SECONDS config settings - Add revoke button to classic subscription settings keyboard and multi-tariff detail keyboard (gated by feature toggle) - Add locale keys for revoke UI in all 5 languages (ru, en, ua, zh, fa) --- app/cabinet/routes/subscription.py | 2 + .../routes/subscription_modules/__init__.py | 2 + .../routes/subscription_modules/revoke.py | 92 +++++ app/config.py | 8 + app/database/models.py | 1 + app/handlers/subscription/my_subscriptions.py | 10 + app/handlers/subscription/purchase.py | 11 + app/handlers/subscription/revoke.py | 350 ++++++++++++++++++ app/keyboards/inline.py | 10 + app/localization/locales/en.json | 12 +- app/localization/locales/fa.json | 12 +- app/localization/locales/ru.json | 12 +- app/localization/locales/ua.json | 12 +- app/localization/locales/zh.json | 12 +- ...071_add_last_revoke_at_to_subscriptions.py | 25 ++ 15 files changed, 566 insertions(+), 5 deletions(-) create mode 100644 app/cabinet/routes/subscription_modules/revoke.py create mode 100644 app/handlers/subscription/revoke.py create mode 100644 migrations/alembic/versions/0071_add_last_revoke_at_to_subscriptions.py 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/config.py b/app/config.py index 9cd6fa5b..92a6ae94 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 @@ -1803,6 +1807,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' diff --git a/app/database/models.py b/app/database/models.py index fa6fc78b..93c3de12 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1808,6 +1808,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/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 98986af7..1b9d24af 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -3119,6 +3119,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/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') From 3fce64858cc4e536ab32acc4c68213c208b6081c Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 17:31:06 +0300 Subject: [PATCH 10/15] fix: add pycryptodome dependency for Antilopay RSA signing --- pyproject.toml | 1 + uv.lock | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 506b2790..2be5b816 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/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" }, From f321ded9c0033ff3848a92aef44cbfef7f8d3883 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 19:36:22 +0300 Subject: [PATCH 11/15] feat: integrate Jupiter (FPGate P2P) and Donut payment providers - Jupiter: SBP via app.juppiter.tech (FPGate P2P v2.1) - Donut: CARD/SBP/SBP_QR via gw.donut.business (Donut P2P) - HMAC-SHA256 signing verified against spec reference vectors - Sticky terminal-status guard in callback (amount_mismatch/declined/cancelled cannot be re-credited by replayed webhook) - Mirrors existing Antilopay/Etoplatezhi mixin pattern: service, mixin, CRUD, Alembic migration, handlers, keyboards, webhook, cabinet route, status mapping - Adds JUPITER and DONUT settings categories with title/description/prefix - Backfills missing ANTILOPAY and ETOPLATEZHI category metadata --- app/cabinet/routes/balance.py | 86 +++ app/config.py | 109 ++++ app/database/crud/donut.py | 159 ++++++ app/database/crud/jupiter.py | 159 ++++++ app/database/models.py | 126 +++++ app/handlers/balance/donut.py | 305 +++++++++++ app/handlers/balance/jupiter.py | 273 ++++++++++ app/handlers/balance/main.py | 31 ++ app/keyboards/inline.py | 77 +++ app/services/donut_service.py | 266 +++++++++ app/services/jupiter_service.py | 268 ++++++++++ app/services/payment/donut.py | 506 ++++++++++++++++++ app/services/payment/jupiter.py | 498 +++++++++++++++++ app/services/payment_method_config_service.py | 22 + app/services/payment_service.py | 118 ++++ app/services/system_settings_service.py | 12 + app/webserver/payments.py | 94 ++++ .../versions/0072_create_jupiter_payments.py | 45 ++ .../versions/0073_create_donut_payments.py | 45 ++ 19 files changed, 3199 insertions(+) create mode 100644 app/database/crud/donut.py create mode 100644 app/database/crud/jupiter.py create mode 100644 app/handlers/balance/donut.py create mode 100644 app/handlers/balance/jupiter.py create mode 100644 app/services/donut_service.py create mode 100644 app/services/jupiter_service.py create mode 100644 app/services/payment/donut.py create mode 100644 app/services/payment/jupiter.py create mode 100644 migrations/alembic/versions/0072_create_jupiter_payments.py create mode 100644 migrations/alembic/versions/0073_create_donut_payments.py diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index c437ab7a..f70f2c20 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -914,6 +914,68 @@ 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', + ) + else: # For other payment methods, redirect to bot raise HTTPException( @@ -1065,6 +1127,30 @@ 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, ('❓', 'Неизвестно')) + return '❓', 'Неизвестно' diff --git a/app/config.py b/app/config.py index 92a6ae94..bad93333 100644 --- a/app/config.py +++ b/app/config.py @@ -712,6 +712,47 @@ class Settings(BaseSettings): 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)' + # Etoplatezhi (paymentpage.etoplatezhi.ru) ETOPLATEZHI_ENABLED: bool = False ETOPLATEZHI_PROJECT_ID: int | None = None @@ -2234,6 +2275,74 @@ class Settings(BaseSettings): 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_etoplatezhi_enabled(self) -> bool: return ( self.ETOPLATEZHI_ENABLED diff --git a/app/database/crud/donut.py b/app/database/crud/donut.py new file mode 100644 index 00000000..0d75b313 --- /dev/null +++ b/app/database/crud/donut.py @@ -0,0 +1,159 @@ +"""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/jupiter.py b/app/database/crud/jupiter.py new file mode 100644 index 00000000..674cef53 --- /dev/null +++ b/app/database/crud/jupiter.py @@ -0,0 +1,159 @@ +"""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/models.py b/app/database/models.py index 93c3de12..a3190f20 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -169,6 +169,8 @@ class PaymentMethod(Enum): AURAPAY = 'aurapay' ETOPLATEZHI = 'etoplatezhi' ANTILOPAY = 'antilopay' + JUPITER = 'jupiter' + DONUT = 'donut' MANUAL = 'manual' BALANCE = 'balance' @@ -1292,6 +1294,130 @@ class AntilopayPayment(Base): 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 PromoGroup(Base): __tablename__ = 'promo_groups' 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/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/main.py b/app/handlers/balance/main.py index 2047371d..4b871421 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -191,6 +191,20 @@ async def route_payment_by_method( 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 == 'riopay': from .riopay import process_riopay_payment_amount @@ -806,6 +820,23 @@ def register_balance_handlers(dp: Dispatcher): 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 .mulenpay import check_mulenpay_payment_status dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_')) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 1b9d24af..521cc7ba 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1983,6 +1983,83 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN ) 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_support_topup_enabled(): keyboard.append( [ 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/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/payment/donut.py b/app/services/payment/donut.py new file mode 100644 index 00000000..e3a11b11 --- /dev/null +++ b/app/services/payment/donut.py @@ -0,0 +1,506 @@ +"""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/jupiter.py b/app/services/payment/jupiter.py new file mode 100644 index 00000000..a29311f3 --- /dev/null +++ b/app/services/payment/jupiter.py @@ -0,0 +1,498 @@ +"""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_method_config_service.py b/app/services/payment_method_config_service.py index ab583bd4..2f2c9122 100644 --- a/app/services/payment_method_config_service.py +++ b/app/services/payment_method_config_service.py @@ -210,6 +210,26 @@ def _get_method_defaults() -> dict: {'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'}, + ], + }, } @@ -258,6 +278,8 @@ DEFAULT_METHOD_ORDER = [ 'aurapay', 'etoplatezhi', 'antilopay', + 'jupiter', + 'donut', ] diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 55955910..06e3d59f 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -33,8 +33,10 @@ from app.services.payment import ( 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.overpay import OverpayPaymentMixin from app.services.payment.paypear import PayPearPaymentMixin @@ -554,6 +556,76 @@ async def link_antilopay_payment_to_transaction(*args, **kwargs): 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) + + # 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] = { @@ -583,6 +655,8 @@ class PaymentService( AuraPayPaymentMixin, EtoplatezhiPaymentMixin, AntilopayPaymentMixin, + JupiterPaymentMixin, + DonutPaymentMixin, ): """Основной интерфейс платежей, делегирующий работу специализированным mixin-ам.""" @@ -1134,6 +1208,50 @@ class PaymentService( } 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 + # --- Telegram Stars --------------------------------------------------- if payment_method == 'telegram_stars': if not settings.TELEGRAM_STARS_ENABLED: diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 47b25c26..1ad28f50 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -95,6 +95,10 @@ class BotConfigurationService: 'ROLLYPAY': '💳 RollyPay', 'OVERPAY': '💳 Overpay', 'AURAPAY': '💳 AuraPay', + 'ANTILOPAY': '🦌 Antilopay', + 'ETOPLATEZHI': '💳 Etoplatezhi', + 'JUPITER': '🪐 Jupiter', + 'DONUT': '🍩 Donut', 'YOOKASSA': '🟣 YooKassa', 'PLATEGA': '💳 {platega_name}', 'TRIBUTE': '🎁 Tribute', @@ -159,6 +163,10 @@ 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.', 'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.', 'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.', 'PAL24': 'PAL24 / PayPalych подключения и лимиты.', @@ -373,6 +381,10 @@ class BotConfigurationService: 'ROLLYPAY_': 'ROLLYPAY', 'OVERPAY_': 'OVERPAY', 'AURAPAY_': 'AURAPAY', + 'ANTILOPAY_': 'ANTILOPAY', + 'ETOPLATEZHI_': 'ETOPLATEZHI', + 'JUPITER_': 'JUPITER', + 'DONUT_': 'DONUT', 'PLATEGA_': 'PLATEGA', 'MULENPAY_': 'MULENPAY', 'PAL24_': 'PAL24', diff --git a/app/webserver/payments.py b/app/webserver/payments.py index 4112a0e8..b1a6effb 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -1564,6 +1564,98 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute 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 + + # 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') @@ -1590,6 +1682,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute '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(), } ) 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') From afea054c8f8898d076d667c2d17248052578cbce Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 20:14:31 +0300 Subject: [PATCH 12/15] feat: integrate Lava Business payment provider - Lava Business via gate.lava.ru (HMAC-SHA256 signed JSON requests) - Sub-methods: card and SBP via includeService filter - Webhook signature verified from raw bytes with secret_key_2 - Sticky terminal-status guard (success after amount_mismatch escalates to ERROR) - Order ID with full uuid4 hex (128-bit entropy) - Cross-row contamination guard: order_id assertion on invoice_id fallback - Warning when hook URL cannot be derived from webhook/web_api/cabinet bases - Explicit failure when Lava response lacks payment_url (no orphan rows) - Adds LAVA settings category, /lava-webhook endpoint, cabinet topup branch - Mirrors existing Antilopay/Jupiter/Donut mixin pattern --- app/cabinet/routes/balance.py | 46 ++ app/config.py | 54 ++ app/database/crud/lava.py | 157 +++++ app/database/models.py | 63 ++ app/handlers/balance/lava.py | 284 +++++++++ app/handlers/balance/main.py | 13 + app/keyboards/inline.py | 40 ++ app/services/lava_service.py | 234 +++++++ app/services/payment/lava.py | 594 ++++++++++++++++++ app/services/payment_method_config_service.py | 11 + app/services/payment_service.py | 59 ++ app/services/system_settings_service.py | 3 + app/webserver/payments.py | 49 ++ .../versions/0074_create_lava_payments.py | 45 ++ 14 files changed, 1652 insertions(+) create mode 100644 app/database/crud/lava.py create mode 100644 app/handlers/balance/lava.py create mode 100644 app/services/lava_service.py create mode 100644 app/services/payment/lava.py create mode 100644 migrations/alembic/versions/0074_create_lava_payments.py diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index f70f2c20..a77d8a8e 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -976,6 +976,37 @@ async def create_topup( 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( @@ -1151,6 +1182,21 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]: } 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/config.py b/app/config.py index bad93333..e7682ad6 100644 --- a/app/config.py +++ b/app/config.py @@ -753,6 +753,25 @@ class Settings(BaseSettings): 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 @@ -2343,6 +2362,41 @@ class Settings(BaseSettings): 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 diff --git a/app/database/crud/lava.py b/app/database/crud/lava.py new file mode 100644 index 00000000..9f4bc981 --- /dev/null +++ b/app/database/crud/lava.py @@ -0,0 +1,157 @@ +"""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/models.py b/app/database/models.py index a3190f20..acdabacd 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -171,6 +171,7 @@ class PaymentMethod(Enum): ANTILOPAY = 'antilopay' JUPITER = 'jupiter' DONUT = 'donut' + LAVA = 'lava' MANUAL = 'manual' BALANCE = 'balance' @@ -1418,6 +1419,68 @@ class DonutPayment(Base): 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' 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 4b871421..ed0ae81c 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -205,6 +205,13 @@ async def route_payment_by_method( 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 @@ -837,6 +844,12 @@ def register_balance_handlers(dp: Dispatcher): 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 dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_')) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 521cc7ba..03935675 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -2060,6 +2060,46 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN ) 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( [ 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/payment/lava.py b/app/services/payment/lava.py new file mode 100644 index 00000000..796b048c --- /dev/null +++ b/app/services/payment/lava.py @@ -0,0 +1,594 @@ +"""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 2f2c9122..6766ac6b 100644 --- a/app/services/payment_method_config_service.py +++ b/app/services/payment_method_config_service.py @@ -230,6 +230,16 @@ def _get_method_defaults() -> dict: {'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': 'СБП'}, + ], + }, } @@ -280,6 +290,7 @@ DEFAULT_METHOD_ORDER = [ 'antilopay', 'jupiter', 'donut', + 'lava', ] diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 06e3d59f..92faa941 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -38,6 +38,7 @@ 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 @@ -626,6 +627,41 @@ async def link_donut_payment_to_transaction(*args, **kwargs): 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] = { @@ -657,6 +693,7 @@ class PaymentService( AntilopayPaymentMixin, JupiterPaymentMixin, DonutPaymentMixin, + LavaPaymentMixin, ): """Основной интерфейс платежей, делегирующий работу специализированным mixin-ам.""" @@ -1252,6 +1289,28 @@ class PaymentService( } 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/system_settings_service.py b/app/services/system_settings_service.py index 1ad28f50..71d707c4 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -99,6 +99,7 @@ class BotConfigurationService: 'ETOPLATEZHI': '💳 Etoplatezhi', 'JUPITER': '🪐 Jupiter', 'DONUT': '🍩 Donut', + 'LAVA': '🌋 Lava', 'YOOKASSA': '🟣 YooKassa', 'PLATEGA': '💳 {platega_name}', 'TRIBUTE': '🎁 Tribute', @@ -167,6 +168,7 @@ class BotConfigurationService: '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 подключения и лимиты.', @@ -385,6 +387,7 @@ class BotConfigurationService: 'ETOPLATEZHI_': 'ETOPLATEZHI', 'JUPITER_': 'JUPITER', 'DONUT_': 'DONUT', + 'LAVA_': 'LAVA', 'PLATEGA_': 'PLATEGA', 'MULENPAY_': 'MULENPAY', 'PAL24_': 'PAL24', diff --git a/app/webserver/payments.py b/app/webserver/payments.py index b1a6effb..fb4b4793 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -1610,6 +1610,54 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute 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(): @@ -1684,6 +1732,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute '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/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') From cd8be326716e2c3253d9b2935da1a7927725c9af Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 20:43:47 +0300 Subject: [PATCH 13/15] fix: register all providers in payment search and verification services The admin Payments page filter and pending payments tab were missing newer providers (paypear, rollypay, aurapay, etoplatezhi, antilopay, jupiter, donut, lava) because they were never registered in the search/verification registries. Customer payments via these providers were invisible in admin filtering. payment_search_service.py: - Add 8 _search_ functions matching the existing pattern - Register them in _PROVIDER_SEARCH_MAP (now 22 methods total) - Filter dropdown and stats.by_method now include all providers payment_verification_service.py: - Add 8 _is__pending and _fetch__payments functions - Wire them into list_recent_pending_payments and get_payment_record - Add display name and is_enabled dispatch branches for all 8 - Register paypear / rollypay / aurapay in SUPPORTED_MANUAL_CHECK_METHODS and SUPPORTED_AUTO_CHECK_METHODS (they have full API+DB sync via check_*) - Wire them into run_manual_check - etoplatezhi / antilopay / jupiter / donut / lava remain webhook-driven and appear in pending tab without manual-check button (no fake API sync) --- app/services/payment_search_service.py | 290 ++++++++++++ app/services/payment_verification_service.py | 461 +++++++++++++++++++ 2 files changed, 751 insertions(+) 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_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 From 17732a03702ee46da483153848ce392a65837483 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 20:47:48 +0300 Subject: [PATCH 14/15] style: apply ruff format to payment integrations CI ruff format --check failed on 13 files. Applied ruff format to bring them in line with project formatting (line wrapping, trailing commas, quote consistency). No functional changes. --- app/config.py | 12 ++---------- app/database/crud/antilopay.py | 4 +--- app/database/crud/donut.py | 8 ++------ app/database/crud/jupiter.py | 4 +--- app/database/crud/lava.py | 4 +--- app/database/models.py | 4 +++- app/keyboards/inline.py | 6 +----- app/services/aurapay_service.py | 4 +--- app/services/payment/antilopay.py | 4 +--- app/services/payment/donut.py | 10 ++-------- app/services/payment/jupiter.py | 8 ++------ app/services/payment/lava.py | 8 ++------ tests/external/test_apple_iap.py | 20 ++++++++++---------- 13 files changed, 29 insertions(+), 67 deletions(-) diff --git a/app/config.py b/app/config.py index e7682ad6..8b6d28d7 100644 --- a/app/config.py +++ b/app/config.py @@ -2295,11 +2295,7 @@ class Settings(BaseSettings): 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 - ) + 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() @@ -2319,11 +2315,7 @@ class Settings(BaseSettings): 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 - ) + 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() diff --git a/app/database/crud/antilopay.py b/app/database/crud/antilopay.py index 37e6e689..535dec87 100644 --- a/app/database/crud/antilopay.py +++ b/app/database/crud/antilopay.py @@ -54,9 +54,7 @@ async def get_antilopay_payment_by_order_id(db: AsyncSession, order_id: str) -> return result.scalar_one_or_none() -async def get_antilopay_payment_by_invoice_id( - db: AsyncSession, antilopay_payment_id: str -) -> AntilopayPayment | 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) diff --git a/app/database/crud/donut.py b/app/database/crud/donut.py index 0d75b313..97073140 100644 --- a/app/database/crud/donut.py +++ b/app/database/crud/donut.py @@ -54,13 +54,9 @@ async def get_donut_payment_by_order_id(db: AsyncSession, order_id: str) -> Donu return result.scalar_one_or_none() -async def get_donut_payment_by_invoice_id( - db: AsyncSession, donut_transaction_id: str -) -> DonutPayment | 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) - ) + result = await db.execute(select(DonutPayment).where(DonutPayment.donut_transaction_id == donut_transaction_id)) return result.scalar_one_or_none() diff --git a/app/database/crud/jupiter.py b/app/database/crud/jupiter.py index 674cef53..d6f683e2 100644 --- a/app/database/crud/jupiter.py +++ b/app/database/crud/jupiter.py @@ -54,9 +54,7 @@ async def get_jupiter_payment_by_order_id(db: AsyncSession, order_id: str) -> Ju return result.scalar_one_or_none() -async def get_jupiter_payment_by_invoice_id( - db: AsyncSession, jupiter_transaction_id: str -) -> JupiterPayment | 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) diff --git a/app/database/crud/lava.py b/app/database/crud/lava.py index 9f4bc981..189490da 100644 --- a/app/database/crud/lava.py +++ b/app/database/crud/lava.py @@ -56,9 +56,7 @@ async def get_lava_payment_by_order_id(db: AsyncSession, order_id: str) -> LavaP 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) - ) + result = await db.execute(select(LavaPayment).where(LavaPayment.lava_invoice_id == lava_invoice_id)) return result.scalar_one_or_none() diff --git a/app/database/models.py b/app/database/models.py index acdabacd..f40d5cae 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1478,7 +1478,9 @@ class LavaPayment(Base): return self.status in ['failed', 'expired', 'cancel', 'cancelled', 'amount_mismatch', 'error'] def __repr__(self) -> str: # pragma: no cover - debug helper - return f'' + return ( + f'' + ) class PromoGroup(Base): diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 03935675..337453a3 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -2084,11 +2084,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN ) has_direct_payment_methods = True - if ( - settings.is_lava_enabled() - and not settings.is_lava_card_enabled() - and not settings.is_lava_sbp_enabled() - ): + 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( [ diff --git a/app/services/aurapay_service.py b/app/services/aurapay_service.py index 372c887c..b62767b3 100644 --- a/app/services/aurapay_service.py +++ b/app/services/aurapay_service.py @@ -199,9 +199,7 @@ class AuraPayService: # Сортируем ключи по алфавиту и конкатенируем значения # None → '' (PHP implode() converts null to empty string, not "None") sorted_keys = sorted(payload.keys()) - concatenated_values = ''.join( - str(payload[key]) if payload[key] is not None else '' 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/payment/antilopay.py b/app/services/payment/antilopay.py index a76385b4..bd7bef63 100644 --- a/app/services/payment/antilopay.py +++ b/app/services/payment/antilopay.py @@ -398,9 +398,7 @@ class AntilopayPaymentMixin: ) created_transaction = True - await antilopay_crud.link_antilopay_payment_to_transaction( - db, payment=payment, transaction_id=transaction.id - ) + 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 diff --git a/app/services/payment/donut.py b/app/services/payment/donut.py index e3a11b11..5a0671a0 100644 --- a/app/services/payment/donut.py +++ b/app/services/payment/donut.py @@ -115,11 +115,7 @@ class DonutPaymentMixin: 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 - ) + payment_url = api_result.get('redirect_url') or details.get('qrcode_url') or actual_return_url logger.info( 'Donut: получен ответ API', @@ -374,9 +370,7 @@ class DonutPaymentMixin: ) created_transaction = True - await donut_crud.link_donut_payment_to_transaction( - db, payment=payment, transaction_id=transaction.id - ) + 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 diff --git a/app/services/payment/jupiter.py b/app/services/payment/jupiter.py index a29311f3..19fcead7 100644 --- a/app/services/payment/jupiter.py +++ b/app/services/payment/jupiter.py @@ -94,9 +94,7 @@ class JupiterPaymentMixin: 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 + getattr(user, 'first_name', None) or getattr(user, 'username', None) or settings.JUPITER_FALLBACK_NAME ) api_result = await jupiter_service.create_payment( @@ -366,9 +364,7 @@ class JupiterPaymentMixin: ) created_transaction = True - await jupiter_crud.link_jupiter_payment_to_transaction( - db, payment=payment, transaction_id=transaction.id - ) + 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 diff --git a/app/services/payment/lava.py b/app/services/payment/lava.py index 796b048c..da05a5fb 100644 --- a/app/services/payment/lava.py +++ b/app/services/payment/lava.py @@ -146,9 +146,7 @@ class LavaPaymentMixin: ) lifetime = settings.LAVA_PAYMENT_LIFETIME_MINUTES - expires_at = self._parse_lava_expired(expired_str) or ( - datetime.now(UTC) + timedelta(minutes=lifetime) - ) + 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( @@ -456,9 +454,7 @@ class LavaPaymentMixin: ) created_transaction = True - await lava_crud.link_lava_payment_to_transaction( - db, payment=payment, transaction_id=transaction.id - ) + 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 diff --git a/tests/external/test_apple_iap.py b/tests/external/test_apple_iap.py index 83610c1d..376a9798 100644 --- a/tests/external/test_apple_iap.py +++ b/tests/external/test_apple_iap.py @@ -35,13 +35,15 @@ def _enable_apple_iap(monkeypatch: pytest.MonkeyPatch) -> None: 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, - }), + 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, ) @@ -297,9 +299,7 @@ class TestVerifyAndDecodeJWS: def test_rejects_empty_x5c(self) -> None: service = AppleIAPService() - header = ( - base64.urlsafe_b64encode(json.dumps({'alg': 'ES256', 'x5c': []}).encode()).rstrip(b'=').decode() - ) + 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 From 0d0646770d2a1cdccec14ea3b78dc12412f95d0a Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 20:50:29 +0300 Subject: [PATCH 15/15] fix(tests): clean up pre-existing ruff lint warnings in apple_iap tests - Remove unused unittest.mock.patch import (F401) - Mark hardcoded /tmp/test.p8 path with noqa S108 (only used for is_apple_iap_enabled check, no file actually accessed) - Replace pytest.raises(Exception) with pytest.raises(ValidationError) for Pydantic schema validation tests (B017) --- tests/external/test_apple_iap.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/external/test_apple_iap.py b/tests/external/test_apple_iap.py index 376a9798..24693941 100644 --- a/tests/external/test_apple_iap.py +++ b/tests/external/test_apple_iap.py @@ -6,9 +6,10 @@ import base64 import json import sys from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest +from pydantic import ValidationError ROOT_DIR = Path(__file__).resolve().parents[2] @@ -133,7 +134,7 @@ class TestAppleIAPEnabled: 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) + monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY_PATH', '/tmp/test.p8', raising=False) # noqa: S108 assert settings.is_apple_iap_enabled() is True @@ -474,7 +475,7 @@ class TestApplePurchaseRequestSchema: def test_rejects_empty_transaction_id(self) -> None: from app.cabinet.schemas.apple_iap import ApplePurchaseRequest - with pytest.raises(Exception): + with pytest.raises(ValidationError): ApplePurchaseRequest( product_id='com.bitnet.vpnclient.topup.100', transaction_id='', @@ -483,7 +484,7 @@ class TestApplePurchaseRequestSchema: def test_rejects_too_long_transaction_id(self) -> None: from app.cabinet.schemas.apple_iap import ApplePurchaseRequest - with pytest.raises(Exception): + with pytest.raises(ValidationError): ApplePurchaseRequest( product_id='com.bitnet.vpnclient.topup.100', transaction_id='1' * 65,