Merge pull request #2924 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-05-04 20:57:17 +03:00
committed by GitHub
69 changed files with 11690 additions and 92 deletions
+12
View File
@@ -69,6 +69,13 @@ from .wheel import router as wheel_router
from .withdrawal import router as withdrawal_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 # Main cabinet router
router = APIRouter(prefix='/cabinet', tags=['Cabinet'], redirect_slashes=False) 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(multi_tariff_subscription_router)
router.include_router(balance_router) router.include_router(balance_router)
router.include_router(referral_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(partner_application_router)
router.include_router(withdrawal_router) router.include_router(withdrawal_router)
# Notifications router MUST be before tickets router to avoid route conflict # Notifications router MUST be before tickets router to avoid route conflict
+267
View File
@@ -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)
+102 -60
View File
@@ -172,74 +172,112 @@ async def _process_campaign_bonus(
db: AsyncSession, db: AsyncSession,
user: User, user: User,
campaign_slug: str | None, campaign_slug: str | None,
telegram_id: int | None = None,
) -> CampaignBonusInfo | 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: if not campaign_slug:
return None return None
try: try:
campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True) try:
if not campaign: campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True)
return None if not campaign:
return None
# Skip if user IS the campaign partner — prevent self-referral # Skip if user IS the campaign partner — prevent self-referral
if campaign.partner_user_id and campaign.partner_user_id == user.id: if campaign.partner_user_id and campaign.partner_user_id == user.id:
logger.debug( logger.debug(
'Skipping campaign attribution: user is the campaign partner', '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',
user_id=user.id, user_id=user.id,
partner_user_id=campaign.partner_user_id,
campaign_id=campaign.id, campaign_id=campaign.id,
) )
except Exception as e: return None
logger.error('Failed to process referral from campaign partner', error=e)
service = AdvertisingCampaignService() # Lock user row to prevent concurrent bonus application (race condition)
result = await service.apply_campaign_bonus(db, user, campaign) await db.execute(select(User).where(User.id == user.id).with_for_update())
if not result.success:
return None
# Refresh user to get updated balance after bonus existing = await get_campaign_registration_by_user(db, user.id)
await db.refresh(user) if existing:
logger.debug('User already has campaign registration', user_id=user.id)
return None
return CampaignBonusInfo( # Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан)
campaign_name=campaign.name, if campaign.partner_user_id and not user.referred_by_id:
bonus_type=result.bonus_type or campaign.bonus_type, user.referred_by_id = campaign.partner_user_id
balance_kopeks=result.balance_kopeks, await db.flush()
subscription_days=result.subscription_days, try:
tariff_name=result.tariff_name, from app.bot_factory import create_bot
)
except Exception: async with create_bot() as bot:
logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug) await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
try: logger.info(
await db.rollback() 'Referral set from campaign partner',
# Re-fetch user so session stays usable for the caller 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) 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: except Exception:
logger.exception('Failed to rollback after campaign bonus error', user_id=user.id) logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug)
return None 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( async def _process_referral_code(
@@ -584,8 +622,11 @@ async def auth_telegram(
except Exception: except Exception:
pass pass
# Process campaign bonus # Process campaign bonus.
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug) # Pass telegram_id so the function can fall back to Redis pending_campaign
# if the user came via /start <campaign> 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: if response.campaign_bonus:
response.user = _user_to_response(user) response.user = _user_to_response(user)
@@ -691,8 +732,8 @@ async def auth_telegram_widget(
except Exception: except Exception:
pass pass
# Process campaign bonus # Process campaign bonus (pending_campaign Redis fallback for Telegram Login Widget)
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug) response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=request.id)
if response.campaign_bonus: if response.campaign_bonus:
response.user = _user_to_response(user) response.user = _user_to_response(user)
@@ -837,7 +878,8 @@ async def auth_telegram_oidc(
except Exception: except Exception:
pass 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: if response.campaign_bonus:
response.user = _user_to_response(user) response.user = _user_to_response(user)
+132
View File
@@ -914,6 +914,99 @@ async def create_topup(
detail='Failed to create AuraPay payment', detail='Failed to create AuraPay payment',
) )
elif request.payment_method == 'jupiter':
if not settings.is_jupiter_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Jupiter payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_jupiter_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_type=payment_method_type,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create Jupiter payment',
)
elif request.payment_method == 'donut':
if not settings.is_donut_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Donut payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_donut_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_type=payment_method_type,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create Donut payment',
)
elif request.payment_method == 'lava':
if not settings.is_lava_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Lava payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_lava_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_type=payment_method_type,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create Lava payment',
)
else: else:
# For other payment methods, redirect to bot # For other payment methods, redirect to bot
raise HTTPException( raise HTTPException(
@@ -1065,6 +1158,45 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
} }
return mapping.get(status, ('', 'Неизвестно')) return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.JUPITER:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'processing': ('', 'Обрабатывается'),
'success': ('', 'Оплачено'),
'cancelled': ('', 'Отменено'),
'declined': ('', 'Отклонено'),
'error': ('', 'Ошибка'),
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.DONUT:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'created': ('', 'Создано'),
'processing': ('', 'Обрабатывается'),
'success': ('', 'Оплачено'),
'cancelled': ('', 'Отменено'),
'error': ('', 'Ошибка'),
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.LAVA:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'created': ('', 'Создано'),
'processing': ('', 'Обрабатывается'),
'success': ('', 'Оплачено'),
'cancel': ('', 'Отменено'),
'cancelled': ('', 'Отменено'),
'expired': ('', 'Истёк'),
'failed': ('', 'Ошибка'),
'error': ('', 'Ошибка'),
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
}
return mapping.get(status, ('', 'Неизвестно'))
return '', 'Неизвестно' return '', 'Неизвестно'
+2
View File
@@ -19,6 +19,7 @@ from .subscription_modules import (
devices_router, devices_router,
purchase_router, purchase_router,
renewal_router, renewal_router,
revoke_router,
servers_router, servers_router,
status_router, status_router,
tariff_switch_router, tariff_switch_router,
@@ -50,3 +51,4 @@ router.include_router(servers_router)
router.include_router(autopay_router) router.include_router(autopay_router)
router.include_router(daily_router) router.include_router(daily_router)
router.include_router(tariff_switch_router) router.include_router(tariff_switch_router)
router.include_router(revoke_router)
@@ -10,6 +10,7 @@ from .devices import router as devices_router
from .multi_tariff import router as multi_tariff_router from .multi_tariff import router as multi_tariff_router
from .purchase import router as purchase_router from .purchase import router as purchase_router
from .renewal import router as renewal_router from .renewal import router as renewal_router
from .revoke import router as revoke_router
from .servers import router as servers_router from .servers import router as servers_router
from .status import router as status_router from .status import router as status_router
from .tariff_switch import router as tariff_switch_router from .tariff_switch import router as tariff_switch_router
@@ -23,6 +24,7 @@ __all__ = [
'multi_tariff_router', 'multi_tariff_router',
'purchase_router', 'purchase_router',
'renewal_router', 'renewal_router',
'revoke_router',
'servers_router', 'servers_router',
'status_router', 'status_router',
'tariff_switch_router', 'tariff_switch_router',
@@ -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,
}
+23
View File
@@ -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
+346
View File
@@ -286,6 +286,10 @@ class Settings(BaseSettings):
DISPOSABLE_EMAIL_CHECK_ENABLED: bool = True 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_ENABLED: bool = False
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30 SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
@@ -616,6 +620,19 @@ class Settings(BaseSettings):
SEVERPAY_RETURN_URL: str | None = None SEVERPAY_RETURN_URL: str | None = None
SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320 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 (paypear.ru)
PAYPEAR_ENABLED: bool = False PAYPEAR_ENABLED: bool = False
PAYPEAR_SHOP_ID: str | None = None PAYPEAR_SHOP_ID: str | None = None
@@ -668,6 +685,108 @@ class Settings(BaseSettings):
AURAPAY_WEBHOOK_PATH: str = '/aurapay-webhook' AURAPAY_WEBHOOK_PATH: str = '/aurapay-webhook'
AURAPAY_RETURN_URL: str | None = None AURAPAY_RETURN_URL: str | None = None
AURAPAY_PAYMENT_LIFETIME_MINUTES: int = 60 AURAPAY_PAYMENT_LIFETIME_MINUTES: int = 60
AURAPAY_SBP_ENABLED: bool = False
AURAPAY_SBP_DISPLAY_NAME: str = 'СБП (AuraPay)'
AURAPAY_CARD_ENABLED: bool = False
AURAPAY_CARD_DISPLAY_NAME: str = 'Карта (AuraPay)'
# Antilopay (lk.antilopay.com)
ANTILOPAY_ENABLED: bool = False
ANTILOPAY_SECRET_ID: str | None = None
ANTILOPAY_PRIVATE_KEY: str | None = None
ANTILOPAY_PUBLIC_KEY: str | None = None
ANTILOPAY_PROJECT_ID: str | None = None
ANTILOPAY_DISPLAY_NAME: str = 'Antilopay'
ANTILOPAY_PRODUCT_NAME: str = 'VPN подписка'
ANTILOPAY_PRODUCT_TYPE: str = 'services'
ANTILOPAY_CURRENCY: str = 'RUB'
ANTILOPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
ANTILOPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
ANTILOPAY_WEBHOOK_PATH: str = '/antilopay-webhook'
ANTILOPAY_RETURN_URL: str | None = None
ANTILOPAY_PAYMENT_LIFETIME_MINUTES: int = 60
ANTILOPAY_SBP_ENABLED: bool = False
ANTILOPAY_SBP_DISPLAY_NAME: str = 'СБП (Antilopay)'
ANTILOPAY_CARD_ENABLED: bool = False
ANTILOPAY_CARD_DISPLAY_NAME: str = 'Карта (Antilopay)'
ANTILOPAY_SBERPAY_ENABLED: bool = False
ANTILOPAY_SBERPAY_DISPLAY_NAME: str = 'SberPay (Antilopay)'
# Jupiter (FPGate P2P v2.1, app.juppiter.tech)
JUPITER_ENABLED: bool = False
JUPITER_TOKEN: str | None = None
JUPITER_SECRET: str | None = None
JUPITER_BASE_URL: str = 'https://app.juppiter.tech'
JUPITER_METHOD_ID: str | None = None
JUPITER_METHOD_DESCRIPTION: str = 'SBP'
JUPITER_DISPLAY_NAME: str = 'Jupiter'
JUPITER_CURRENCY: str = 'RUB'
JUPITER_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
JUPITER_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
JUPITER_WEBHOOK_PATH: str = '/jupiter-webhook'
JUPITER_RETURN_URL: str | None = None
JUPITER_PAYMENT_LIFETIME_MINUTES: int = 60
JUPITER_FALLBACK_EMAIL: str = 'user@vpn.bot'
JUPITER_FALLBACK_PHONE: str = '0000000000'
JUPITER_FALLBACK_NAME: str = 'User'
JUPITER_SBP_ENABLED: bool = False
JUPITER_SBP_DISPLAY_NAME: str = 'СБП (Jupiter)'
# Donut (Donut P2P, gw.donut.business)
DONUT_ENABLED: bool = False
DONUT_TOKEN: str | None = None
DONUT_SECRET: str | None = None
DONUT_BASE_URL: str = 'https://gw.donut.business'
DONUT_METHOD_ID: str | None = None
DONUT_DISPLAY_NAME: str = 'Donut'
DONUT_CURRENCY: str = 'RUB'
DONUT_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
DONUT_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
DONUT_WEBHOOK_PATH: str = '/donut-webhook'
DONUT_RETURN_URL: str | None = None
DONUT_PAYMENT_LIFETIME_MINUTES: int = 60
# Sub-методы Donut (description в PayIn запросе)
DONUT_CARD_ENABLED: bool = False
DONUT_CARD_DISPLAY_NAME: str = 'Карта (Donut)'
DONUT_SBP_ENABLED: bool = False
DONUT_SBP_DISPLAY_NAME: str = 'СБП (Donut)'
DONUT_SBP_QR_ENABLED: bool = False
DONUT_SBP_QR_DISPLAY_NAME: str = 'СБП QR (Donut)'
# Lava (Lava Business API, gate.lava.ru)
LAVA_ENABLED: bool = False
LAVA_BASE_URL: str = 'https://gate.lava.ru'
LAVA_SHOP_ID: str | None = None # UUID проекта
LAVA_SECRET_KEY: str | None = None # secret_key — для подписи запросов
LAVA_WEBHOOK_SECRET: str | None = None # secret_key_2 — для проверки подписи webhook
LAVA_DISPLAY_NAME: str = 'Lava'
LAVA_CURRENCY: str = 'RUB'
LAVA_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
LAVA_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
LAVA_WEBHOOK_PATH: str = '/lava-webhook'
LAVA_RETURN_URL: str | None = None
LAVA_PAYMENT_LIFETIME_MINUTES: int = 60 # макс 7200 минут (5 дней)
# Sub-методы Lava (фильтр через includeService/excludeService на стороне Lava)
LAVA_CARD_ENABLED: bool = False
LAVA_CARD_DISPLAY_NAME: str = 'Карта (Lava)'
LAVA_SBP_ENABLED: bool = False
LAVA_SBP_DISPLAY_NAME: str = 'СБП (Lava)'
# Etoplatezhi (paymentpage.etoplatezhi.ru)
ETOPLATEZHI_ENABLED: bool = False
ETOPLATEZHI_PROJECT_ID: int | None = None
ETOPLATEZHI_SECRET_KEY: str | None = None
ETOPLATEZHI_DISPLAY_NAME: str = 'Etoplatezhi'
ETOPLATEZHI_CURRENCY: str = 'RUB'
ETOPLATEZHI_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
ETOPLATEZHI_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
ETOPLATEZHI_WEBHOOK_PATH: str = '/etoplatezhi-webhook'
ETOPLATEZHI_RETURN_URL: str | None = None
ETOPLATEZHI_PAYMENT_LIFETIME_MINUTES: int = 60
ETOPLATEZHI_SBP_ENABLED: bool = False
ETOPLATEZHI_SBP_DISPLAY_NAME: str = 'СБП (Etoplatezhi)'
ETOPLATEZHI_CARD_ENABLED: bool = False
ETOPLATEZHI_CARD_DISPLAY_NAME: str = 'Карта (Etoplatezhi)'
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet' MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции) # Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
@@ -1748,6 +1867,10 @@ class Settings(BaseSettings):
def get_disabled_mode_device_limit(self) -> int | None: def get_disabled_mode_device_limit(self) -> int | None:
return self.get_devices_selection_disabled_amount() 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: def is_multi_tariff_enabled(self) -> bool:
"""Проверяет, включен ли мультитарифный режим.""" """Проверяет, включен ли мультитарифный режим."""
return self.MULTI_TARIFF_ENABLED and self.SALES_MODE == 'tariffs' return self.MULTI_TARIFF_ENABLED and self.SALES_MODE == 'tariffs'
@@ -2027,6 +2150,34 @@ class Settings(BaseSettings):
def get_severpay_display_name_html(self) -> str: def get_severpay_display_name_html(self) -> str:
return html.escape(self.get_severpay_display_name()) 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: 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 return self.PAYPEAR_ENABLED and self.PAYPEAR_SHOP_ID is not None and self.PAYPEAR_SECRET_KEY is not None
@@ -2077,6 +2228,201 @@ class Settings(BaseSettings):
def get_aurapay_display_name_html(self) -> str: def get_aurapay_display_name_html(self) -> str:
return html.escape(self.get_aurapay_display_name()) return html.escape(self.get_aurapay_display_name())
def is_aurapay_sbp_enabled(self) -> bool:
return self.AURAPAY_SBP_ENABLED and self.is_aurapay_enabled()
def get_aurapay_sbp_display_name(self) -> str:
name = (self.AURAPAY_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (AuraPay)'
def get_aurapay_sbp_display_name_html(self) -> str:
return html.escape(self.get_aurapay_sbp_display_name())
def is_aurapay_card_enabled(self) -> bool:
return self.AURAPAY_CARD_ENABLED and self.is_aurapay_enabled()
def get_aurapay_card_display_name(self) -> str:
name = (self.AURAPAY_CARD_DISPLAY_NAME or '').strip()
return name or 'Карта (AuraPay)'
def get_aurapay_card_display_name_html(self) -> str:
return html.escape(self.get_aurapay_card_display_name())
def is_antilopay_enabled(self) -> bool:
return (
self.ANTILOPAY_ENABLED
and self.ANTILOPAY_SECRET_ID is not None
and self.ANTILOPAY_PRIVATE_KEY is not None
and self.ANTILOPAY_PUBLIC_KEY is not None
and self.ANTILOPAY_PROJECT_ID is not None
)
def get_antilopay_display_name(self) -> str:
name = (self.ANTILOPAY_DISPLAY_NAME or '').strip()
return name if name else 'Antilopay'
def get_antilopay_display_name_html(self) -> str:
return html.escape(self.get_antilopay_display_name())
def is_antilopay_sbp_enabled(self) -> bool:
return self.ANTILOPAY_SBP_ENABLED and self.is_antilopay_enabled()
def get_antilopay_sbp_display_name(self) -> str:
name = (self.ANTILOPAY_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (Antilopay)'
def get_antilopay_sbp_display_name_html(self) -> str:
return html.escape(self.get_antilopay_sbp_display_name())
def is_antilopay_card_enabled(self) -> bool:
return self.ANTILOPAY_CARD_ENABLED and self.is_antilopay_enabled()
def get_antilopay_card_display_name(self) -> str:
name = (self.ANTILOPAY_CARD_DISPLAY_NAME or '').strip()
return name or 'Карта (Antilopay)'
def get_antilopay_card_display_name_html(self) -> str:
return html.escape(self.get_antilopay_card_display_name())
def is_antilopay_sberpay_enabled(self) -> bool:
return self.ANTILOPAY_SBERPAY_ENABLED and self.is_antilopay_enabled()
def get_antilopay_sberpay_display_name(self) -> str:
name = (self.ANTILOPAY_SBERPAY_DISPLAY_NAME or '').strip()
return name or 'SberPay (Antilopay)'
def get_antilopay_sberpay_display_name_html(self) -> str:
return html.escape(self.get_antilopay_sberpay_display_name())
def is_jupiter_enabled(self) -> bool:
return self.JUPITER_ENABLED and self.JUPITER_TOKEN is not None and self.JUPITER_SECRET is not None
def get_jupiter_display_name(self) -> str:
name = (self.JUPITER_DISPLAY_NAME or '').strip()
return name if name else 'Jupiter'
def get_jupiter_display_name_html(self) -> str:
return html.escape(self.get_jupiter_display_name())
def is_jupiter_sbp_enabled(self) -> bool:
return self.JUPITER_SBP_ENABLED and self.is_jupiter_enabled()
def get_jupiter_sbp_display_name(self) -> str:
name = (self.JUPITER_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (Jupiter)'
def get_jupiter_sbp_display_name_html(self) -> str:
return html.escape(self.get_jupiter_sbp_display_name())
def is_donut_enabled(self) -> bool:
return self.DONUT_ENABLED and self.DONUT_TOKEN is not None and self.DONUT_SECRET is not None
def get_donut_display_name(self) -> str:
name = (self.DONUT_DISPLAY_NAME or '').strip()
return name if name else 'Donut'
def get_donut_display_name_html(self) -> str:
return html.escape(self.get_donut_display_name())
def is_donut_card_enabled(self) -> bool:
return self.DONUT_CARD_ENABLED and self.is_donut_enabled()
def get_donut_card_display_name(self) -> str:
name = (self.DONUT_CARD_DISPLAY_NAME or '').strip()
return name or 'Карта (Donut)'
def get_donut_card_display_name_html(self) -> str:
return html.escape(self.get_donut_card_display_name())
def is_donut_sbp_enabled(self) -> bool:
return self.DONUT_SBP_ENABLED and self.is_donut_enabled()
def get_donut_sbp_display_name(self) -> str:
name = (self.DONUT_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (Donut)'
def get_donut_sbp_display_name_html(self) -> str:
return html.escape(self.get_donut_sbp_display_name())
def is_donut_sbp_qr_enabled(self) -> bool:
return self.DONUT_SBP_QR_ENABLED and self.is_donut_enabled()
def get_donut_sbp_qr_display_name(self) -> str:
name = (self.DONUT_SBP_QR_DISPLAY_NAME or '').strip()
return name or 'СБП QR (Donut)'
def get_donut_sbp_qr_display_name_html(self) -> str:
return html.escape(self.get_donut_sbp_qr_display_name())
def is_lava_enabled(self) -> bool:
return (
self.LAVA_ENABLED
and self.LAVA_SHOP_ID is not None
and self.LAVA_SECRET_KEY is not None
and self.LAVA_WEBHOOK_SECRET is not None
)
def get_lava_display_name(self) -> str:
name = (self.LAVA_DISPLAY_NAME or '').strip()
return name if name else 'Lava'
def get_lava_display_name_html(self) -> str:
return html.escape(self.get_lava_display_name())
def is_lava_card_enabled(self) -> bool:
return self.LAVA_CARD_ENABLED and self.is_lava_enabled()
def get_lava_card_display_name(self) -> str:
name = (self.LAVA_CARD_DISPLAY_NAME or '').strip()
return name or 'Карта (Lava)'
def get_lava_card_display_name_html(self) -> str:
return html.escape(self.get_lava_card_display_name())
def is_lava_sbp_enabled(self) -> bool:
return self.LAVA_SBP_ENABLED and self.is_lava_enabled()
def get_lava_sbp_display_name(self) -> str:
name = (self.LAVA_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (Lava)'
def get_lava_sbp_display_name_html(self) -> str:
return html.escape(self.get_lava_sbp_display_name())
def is_etoplatezhi_enabled(self) -> bool:
return (
self.ETOPLATEZHI_ENABLED
and self.ETOPLATEZHI_PROJECT_ID is not None
and self.ETOPLATEZHI_SECRET_KEY is not None
)
def get_etoplatezhi_display_name(self) -> str:
name = (self.ETOPLATEZHI_DISPLAY_NAME or '').strip()
return name if name else 'Etoplatezhi'
def get_etoplatezhi_display_name_html(self) -> str:
return html.escape(self.get_etoplatezhi_display_name())
def is_etoplatezhi_sbp_enabled(self) -> bool:
return self.ETOPLATEZHI_SBP_ENABLED and self.is_etoplatezhi_enabled()
def get_etoplatezhi_sbp_display_name(self) -> str:
name = (self.ETOPLATEZHI_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (Etoplatezhi)'
def get_etoplatezhi_sbp_display_name_html(self) -> str:
return html.escape(self.get_etoplatezhi_sbp_display_name())
def is_etoplatezhi_card_enabled(self) -> bool:
return self.ETOPLATEZHI_CARD_ENABLED and self.is_etoplatezhi_enabled()
def get_etoplatezhi_card_display_name(self) -> str:
name = (self.ETOPLATEZHI_CARD_DISPLAY_NAME or '').strip()
return name or 'Карта (Etoplatezhi)'
def get_etoplatezhi_card_display_name_html(self) -> str:
return html.escape(self.get_etoplatezhi_card_display_name())
def is_kassa_ai_sbp_enabled(self) -> bool: def is_kassa_ai_sbp_enabled(self) -> bool:
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled() return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
+159
View File
@@ -0,0 +1,159 @@
"""CRUD операции для платежей Antilopay."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import AntilopayPayment
logger = structlog.get_logger(__name__)
async def create_antilopay_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
antilopay_payment_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> AntilopayPayment:
"""Создает запись о платеже Antilopay."""
payment = AntilopayPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
antilopay_payment_id=antilopay_payment_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж Antilopay', order_id=order_id, user_id=user_id)
return payment
async def get_antilopay_payment_by_order_id(db: AsyncSession, order_id: str) -> AntilopayPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(AntilopayPayment).where(AntilopayPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_antilopay_payment_by_invoice_id(db: AsyncSession, antilopay_payment_id: str) -> AntilopayPayment | None:
"""Получает платеж по ID от Antilopay."""
result = await db.execute(
select(AntilopayPayment).where(AntilopayPayment.antilopay_payment_id == antilopay_payment_id)
)
return result.scalar_one_or_none()
async def get_antilopay_payment_by_id(db: AsyncSession, payment_id: int) -> AntilopayPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(AntilopayPayment).where(AntilopayPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_antilopay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> AntilopayPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(
select(AntilopayPayment)
.where(AntilopayPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_antilopay_payment_status(
db: AsyncSession,
payment: AntilopayPayment,
*,
status: str,
is_paid: bool | None = None,
antilopay_payment_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> AntilopayPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if antilopay_payment_id is not None:
payment.antilopay_payment_id = antilopay_payment_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен статус платежа Antilopay',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_antilopay_payments(db: AsyncSession, user_id: int) -> list[AntilopayPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(AntilopayPayment).where(
AntilopayPayment.user_id == user_id,
AntilopayPayment.status == 'pending',
AntilopayPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_antilopay_payments(
db: AsyncSession,
) -> list[AntilopayPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(AntilopayPayment).where(
AntilopayPayment.status == 'pending',
AntilopayPayment.is_paid == False,
AntilopayPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_antilopay_payment_to_transaction(
db: AsyncSession,
*,
payment: AntilopayPayment,
transaction_id: int,
) -> AntilopayPayment:
"""Связывает платеж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+83
View File
@@ -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
+155
View File
@@ -0,0 +1,155 @@
"""CRUD операции для платежей Donut (Donut P2P)."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import DonutPayment
logger = structlog.get_logger(__name__)
async def create_donut_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
donut_transaction_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> DonutPayment:
"""Создаёт запись о платеже Donut."""
payment = DonutPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
donut_transaction_id=donut_transaction_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж Donut', order_id=order_id, user_id=user_id)
return payment
async def get_donut_payment_by_order_id(db: AsyncSession, order_id: str) -> DonutPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(DonutPayment).where(DonutPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_donut_payment_by_invoice_id(db: AsyncSession, donut_transaction_id: str) -> DonutPayment | None:
"""Получает платёж по transaction_id, выданному Donut."""
result = await db.execute(select(DonutPayment).where(DonutPayment.donut_transaction_id == donut_transaction_id))
return result.scalar_one_or_none()
async def get_donut_payment_by_id(db: AsyncSession, payment_id: int) -> DonutPayment | None:
"""Получает платеж по локальному ID."""
result = await db.execute(select(DonutPayment).where(DonutPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_donut_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> DonutPayment | None:
"""Получает платёж с блокировкой FOR UPDATE."""
result = await db.execute(
select(DonutPayment)
.where(DonutPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_donut_payment_status(
db: AsyncSession,
payment: DonutPayment,
*,
status: str,
is_paid: bool | None = None,
donut_transaction_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> DonutPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if donut_transaction_id is not None:
payment.donut_transaction_id = donut_transaction_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлён статус платежа Donut',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_donut_payments(db: AsyncSession, user_id: int) -> list[DonutPayment]:
"""Возвращает незавершённые платежи пользователя."""
result = await db.execute(
select(DonutPayment).where(
DonutPayment.user_id == user_id,
DonutPayment.status == 'pending',
DonutPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_donut_payments(db: AsyncSession) -> list[DonutPayment]:
"""Возвращает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(DonutPayment).where(
DonutPayment.status == 'pending',
DonutPayment.is_paid == False,
DonutPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_donut_payment_to_transaction(
db: AsyncSession,
*,
payment: DonutPayment,
transaction_id: int,
) -> DonutPayment:
"""Связывает платёж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+161
View File
@@ -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
+157
View File
@@ -0,0 +1,157 @@
"""CRUD операции для платежей Jupiter (FPGate P2P v2.1)."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import JupiterPayment
logger = structlog.get_logger(__name__)
async def create_jupiter_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
jupiter_transaction_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> JupiterPayment:
"""Создаёт запись о платеже Jupiter."""
payment = JupiterPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
jupiter_transaction_id=jupiter_transaction_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж Jupiter', order_id=order_id, user_id=user_id)
return payment
async def get_jupiter_payment_by_order_id(db: AsyncSession, order_id: str) -> JupiterPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(JupiterPayment).where(JupiterPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_jupiter_payment_by_invoice_id(db: AsyncSession, jupiter_transaction_id: str) -> JupiterPayment | None:
"""Получает платёж по transaction_id, выданному Jupiter."""
result = await db.execute(
select(JupiterPayment).where(JupiterPayment.jupiter_transaction_id == jupiter_transaction_id)
)
return result.scalar_one_or_none()
async def get_jupiter_payment_by_id(db: AsyncSession, payment_id: int) -> JupiterPayment | None:
"""Получает платеж по локальному ID."""
result = await db.execute(select(JupiterPayment).where(JupiterPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_jupiter_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> JupiterPayment | None:
"""Получает платёж с блокировкой FOR UPDATE."""
result = await db.execute(
select(JupiterPayment)
.where(JupiterPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_jupiter_payment_status(
db: AsyncSession,
payment: JupiterPayment,
*,
status: str,
is_paid: bool | None = None,
jupiter_transaction_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> JupiterPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if jupiter_transaction_id is not None:
payment.jupiter_transaction_id = jupiter_transaction_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлён статус платежа Jupiter',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_jupiter_payments(db: AsyncSession, user_id: int) -> list[JupiterPayment]:
"""Возвращает незавершённые платежи пользователя."""
result = await db.execute(
select(JupiterPayment).where(
JupiterPayment.user_id == user_id,
JupiterPayment.status == 'pending',
JupiterPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_jupiter_payments(db: AsyncSession) -> list[JupiterPayment]:
"""Возвращает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(JupiterPayment).where(
JupiterPayment.status == 'pending',
JupiterPayment.is_paid == False,
JupiterPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_jupiter_payment_to_transaction(
db: AsyncSession,
*,
payment: JupiterPayment,
transaction_id: int,
) -> JupiterPayment:
"""Связывает платёж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+155
View File
@@ -0,0 +1,155 @@
"""CRUD операции для платежей Lava (Lava Business)."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import LavaPayment
logger = structlog.get_logger(__name__)
async def create_lava_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
lava_invoice_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> LavaPayment:
"""Создаёт запись о платеже Lava."""
payment = LavaPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
lava_invoice_id=lava_invoice_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж Lava', order_id=order_id, user_id=user_id)
return payment
async def get_lava_payment_by_order_id(db: AsyncSession, order_id: str) -> LavaPayment | None:
"""Получает платёж по нашему orderId."""
result = await db.execute(select(LavaPayment).where(LavaPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_lava_payment_by_invoice_id(db: AsyncSession, lava_invoice_id: str) -> LavaPayment | None:
"""Получает платёж по invoice_id, выданному Lava."""
result = await db.execute(select(LavaPayment).where(LavaPayment.lava_invoice_id == lava_invoice_id))
return result.scalar_one_or_none()
async def get_lava_payment_by_id(db: AsyncSession, payment_id: int) -> LavaPayment | None:
"""Получает платёж по локальному ID."""
result = await db.execute(select(LavaPayment).where(LavaPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_lava_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> LavaPayment | None:
"""Получает платёж с FOR UPDATE-блокировкой."""
result = await db.execute(
select(LavaPayment)
.where(LavaPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_lava_payment_status(
db: AsyncSession,
payment: LavaPayment,
*,
status: str,
is_paid: bool | None = None,
lava_invoice_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> LavaPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if lava_invoice_id is not None:
payment.lava_invoice_id = lava_invoice_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлён статус платежа Lava',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_lava_payments(db: AsyncSession, user_id: int) -> list[LavaPayment]:
"""Возвращает незавершённые платежи пользователя."""
result = await db.execute(
select(LavaPayment).where(
LavaPayment.user_id == user_id,
LavaPayment.status == 'pending',
LavaPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_lava_payments(db: AsyncSession) -> list[LavaPayment]:
"""Возвращает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(LavaPayment).where(
LavaPayment.status == 'pending',
LavaPayment.is_paid == False,
LavaPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_lava_payment_to_transaction(
db: AsyncSession,
*,
payment: LavaPayment,
transaction_id: int,
) -> LavaPayment:
"""Связывает платёж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+2
View File
@@ -31,6 +31,8 @@ REAL_PAYMENT_METHODS = [
PaymentMethod.PAYPEAR.value, PaymentMethod.PAYPEAR.value,
PaymentMethod.OVERPAY.value, PaymentMethod.OVERPAY.value,
PaymentMethod.AURAPAY.value, PaymentMethod.AURAPAY.value,
PaymentMethod.ETOPLATEZHI.value,
PaymentMethod.ANTILOPAY.value,
] ]
+354
View File
@@ -162,10 +162,16 @@ class PaymentMethod(Enum):
KASSA_AI = 'kassa_ai' KASSA_AI = 'kassa_ai'
RIOPAY = 'riopay' RIOPAY = 'riopay'
SEVERPAY = 'severpay' SEVERPAY = 'severpay'
APPLE_IAP = 'apple_iap'
PAYPEAR = 'paypear' PAYPEAR = 'paypear'
ROLLYPAY = 'rollypay' ROLLYPAY = 'rollypay'
OVERPAY = 'overpay' OVERPAY = 'overpay'
AURAPAY = 'aurapay' AURAPAY = 'aurapay'
ETOPLATEZHI = 'etoplatezhi'
ANTILOPAY = 'antilopay'
JUPITER = 'jupiter'
DONUT = 'donut'
LAVA = 'lava'
MANUAL = 'manual' MANUAL = 'manual'
BALANCE = 'balance' BALANCE = 'balance'
@@ -329,6 +335,41 @@ class CryptoBotPayment(Base):
return f'<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>' return f'<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>'
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'<AppleTransaction(id={self.id}, txn={self.transaction_id}, product={self.product_id}, status={self.status})>'
class HeleketPayment(Base): class HeleketPayment(Base):
__tablename__ = 'heleket_payments' __tablename__ = 'heleket_payments'
@@ -1130,6 +1171,318 @@ class AuraPayPayment(Base):
return f'<AuraPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>' return f'<AuraPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
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'<EtoplatezhiPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
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'<AntilopayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
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'<JupiterPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
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'<DonutPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
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'<LavaPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
)
class PromoGroup(Base): class PromoGroup(Base):
__tablename__ = 'promo_groups' __tablename__ = 'promo_groups'
@@ -1646,6 +1999,7 @@ class Subscription(Base):
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now()) updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
last_webhook_update_at = Column(AwareDateTime(), nullable=True) last_webhook_update_at = Column(AwareDateTime(), nullable=True)
last_revoke_at = Column(AwareDateTime(), nullable=True)
remnawave_short_uuid = Column(String(255), nullable=True) remnawave_short_uuid = Column(String(255), nullable=True)
remnawave_uuid = Column(String(255), nullable=True) remnawave_uuid = Column(String(255), nullable=True)
+411
View File
@@ -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
+339
View File
@@ -57,6 +57,9 @@ class WebhookServer:
self.app.router.add_get('/health', self._health_check) 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) self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
if settings.is_mulenpay_enabled(): if settings.is_mulenpay_enabled():
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler) 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) self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
if settings.is_freekassa_enabled(): if settings.is_freekassa_enabled():
self.app.router.add_options(settings.FREEKASSA_WEBHOOK_PATH, self._options_handler) 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('Webhook сервер настроен:')
logger.info('Tribute webhook: POST', TRIBUTE_WEBHOOK_PATH=settings.TRIBUTE_WEBHOOK_PATH) 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) logger.info('CryptoBot webhook: POST', CRYPTOBOT_WEBHOOK_PATH=settings.CRYPTOBOT_WEBHOOK_PATH)
if settings.is_freekassa_enabled(): if settings.is_freekassa_enabled():
logger.info('Freekassa webhook: POST', FREEKASSA_WEBHOOK_PATH=settings.FREEKASSA_WEBHOOK_PATH) 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') logger.info(' - Health check: GET /health')
return self.app return self.app
@@ -491,3 +498,335 @@ class WebhookServer:
except Exception as e: except Exception as e:
logger.error('Критическая ошибка обработки Freekassa webhook', error=e, exc_info=True) logger.error('Критическая ошибка обработки Freekassa webhook', error=e, exc_info=True)
return web.Response(text='NO', status=500) return web.Response(text='NO', status=500)
async def _apple_iap_webhook_handler(self, request: web.Request) -> web.Response:
"""Handle Apple App Store Server Notifications V2."""
try:
logger.info('Получен Apple IAP webhook', method=request.method, path=request.path)
raw_body = await request.read()
if not raw_body:
logger.warning('Пустой Apple IAP webhook')
return web.Response(status=400)
try:
body = json.loads(raw_body.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
logger.error('Ошибка парсинга Apple IAP webhook', error=e)
return web.Response(status=400)
signed_payload = body.get('signedPayload')
if not signed_payload:
logger.warning('No signedPayload in Apple webhook')
return web.Response(status=400)
# Verify and decode the notification
from app.external.apple_iap import AppleIAPService
apple_service = AppleIAPService()
notification = apple_service.verify_notification(signed_payload)
if not notification:
logger.warning('Apple webhook signature verification failed')
return web.Response(status=403)
notification_type = notification.get('notificationType', '')
subtype = notification.get('subtype', '')
# Verify notification environment matches our config
# FIX 11: removed dead initial assignment of expected_envs
notif_env = notification.get('data', {}).get('environment', '')
if settings.APPLE_IAP_ENVIRONMENT == 'Production':
expected_envs = {'Production', 'Sandbox'} # Sandbox for App Review
else:
expected_envs = {'Sandbox'}
if notif_env and notif_env not in expected_envs:
logger.warning(
'Apple webhook environment mismatch',
expected=settings.APPLE_IAP_ENVIRONMENT,
received=notif_env,
)
return web.Response(status=200) # ACK but ignore
logger.info(
'Apple notification received',
notification_type=notification_type,
subtype=subtype,
environment=notif_env,
)
# Handle notification types
if notification_type == 'TEST':
logger.info('Apple TEST notification received -- OK')
return web.Response(status=200)
if notification_type == 'REFUND':
await self._handle_apple_refund(notification, apple_service)
return web.Response(status=200)
if notification_type == 'REFUND_REVERSED':
await self._handle_apple_refund_reversed(notification)
return web.Response(status=200)
if notification_type == 'CONSUMPTION_REQUEST':
await self._handle_apple_consumption_request(notification, apple_service)
return web.Response(status=200)
if notification_type in ('ONE_TIME_CHARGE', 'REFUND_DECLINED'):
logger.info('Apple notification logged', notification_type=notification_type)
return web.Response(status=200)
logger.info('Unhandled Apple notification type', notification_type=notification_type)
return web.Response(status=200)
except Exception as e:
logger.error('Критическая ошибка обработки Apple IAP webhook', error=e, exc_info=True)
return web.Response(status=500)
async def _handle_apple_refund(self, notification: dict, apple_service) -> None:
"""Handle REFUND notification -- deduct credited balance."""
try:
data = notification.get('data', {})
signed_txn_info = data.get('signedTransactionInfo')
if not signed_txn_info:
logger.warning('No signedTransactionInfo in REFUND notification')
return
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
if not txn_info:
logger.warning('Failed to verify REFUND transaction info')
return
apple_txn_id = str(txn_info.get('transactionId') or '')
original_txn_id = str(txn_info.get('originalTransactionId') or '')
product_id = txn_info.get('productId', '')
from app.database.crud.apple_iap import (
mark_apple_transaction_refunded,
)
from app.database.crud.user import lock_user_for_pricing
from app.database.database import AsyncSessionLocal
from app.database.models import PaymentMethod, TransactionType
lookup_id = original_txn_id or apple_txn_id
async with AsyncSessionLocal() as db:
from app.database.crud.apple_iap import get_apple_transaction_by_transaction_id_for_update
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, lookup_id)
if not apple_txn:
# Try the other ID
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, apple_txn_id)
if not apple_txn:
logger.warning(
'Apple REFUND: transaction not found',
transaction_id=apple_txn_id,
original_transaction_id=original_txn_id,
)
return
if apple_txn.status == 'refunded':
logger.info('Apple REFUND: already refunded', transaction_id=lookup_id)
return
if apple_txn.environment == 'Sandbox' and settings.APPLE_IAP_ENVIRONMENT == 'Production':
logger.info(
'Apple REFUND: ignoring sandbox refund on production',
transaction_id=lookup_id,
user_id=apple_txn.user_id,
)
return
# FIX 6: Lock user row with FOR UPDATE before reading balance
# to prevent race condition in min() balance cap calculation
user = await lock_user_for_pricing(db, apple_txn.user_id)
if not user:
logger.error('Apple REFUND: user not found', user_id=apple_txn.user_id)
return
# Cap deduction to current balance to prevent negative balance
refund_amount = min(apple_txn.amount_kopeks, user.balance_kopeks)
if refund_amount < apple_txn.amount_kopeks:
logger.warning(
'Apple REFUND: partial balance deduction (user already spent funds)',
full_amount=apple_txn.amount_kopeks,
deducted=refund_amount,
user_balance=user.balance_kopeks,
user_id=user.id,
)
# Disable active subscriptions -- funds were spent and refunded
from app.database.crud.subscription import (
deactivate_subscription,
get_active_subscriptions_by_user_id,
)
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
for sub in active_subs:
await deactivate_subscription(db, sub, commit=False)
logger.warning(
'Apple REFUND: disabled subscription due to insufficient balance',
subscription_id=sub.id,
user_id=user.id,
)
if refund_amount > 0:
from app.database.crud.user import subtract_user_balance
await subtract_user_balance(
db=db,
user=user,
amount_kopeks=refund_amount,
description=f'Возврат Apple IAP: {product_id}',
create_transaction=True,
payment_method=PaymentMethod.APPLE_IAP,
transaction_type=TransactionType.REFUND,
commit=False,
)
await mark_apple_transaction_refunded(db, apple_txn.transaction_id)
await db.commit()
logger.info(
'Apple REFUND processed',
transaction_id=apple_txn.transaction_id,
amount_kopeks=apple_txn.amount_kopeks,
user_id=user.id,
)
except Exception as e:
logger.error('Error handling Apple REFUND', error=e, exc_info=True)
async def _handle_apple_refund_reversed(self, notification: dict) -> None:
"""Handle REFUND_REVERSED -- re-credit balance that was previously deducted."""
try:
data = notification.get('data', {})
signed_txn_info = data.get('signedTransactionInfo')
if not signed_txn_info:
logger.warning('No signedTransactionInfo in REFUND_REVERSED notification')
return
from app.external.apple_iap import AppleIAPService
apple_service = AppleIAPService()
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
if not txn_info:
logger.warning('Failed to verify REFUND_REVERSED transaction info')
return
apple_txn_id = str(txn_info.get('transactionId') or '')
original_txn_id = str(txn_info.get('originalTransactionId') or '')
product_id = txn_info.get('productId', '')
from app.database.crud.apple_iap import (
get_apple_transaction_by_transaction_id_for_update,
)
from app.database.crud.user import add_user_balance, get_user_by_id
from app.database.database import AsyncSessionLocal
from app.database.models import PaymentMethod
lookup_id = original_txn_id or apple_txn_id
async with AsyncSessionLocal() as db:
# FIX 7: Use FOR UPDATE lock on apple_transactions row
# before checking status to prevent idempotency race
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, lookup_id)
if not apple_txn:
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, apple_txn_id)
if not apple_txn:
logger.warning(
'Apple REFUND_REVERSED: transaction not found',
transaction_id=apple_txn_id,
)
return
if apple_txn.status != 'refunded':
logger.info(
'Apple REFUND_REVERSED: transaction not in refunded state',
transaction_id=lookup_id,
status=apple_txn.status,
)
return
if apple_txn.environment == 'Sandbox' and settings.APPLE_IAP_ENVIRONMENT == 'Production':
logger.info(
'Apple REFUND_REVERSED: ignoring sandbox on production',
transaction_id=lookup_id,
)
return
user = await get_user_by_id(db, apple_txn.user_id)
if not user:
logger.error('Apple REFUND_REVERSED: user not found', user_id=apple_txn.user_id)
return
# Re-credit the balance
await add_user_balance(
db=db,
user=user,
amount_kopeks=apple_txn.amount_kopeks,
description=f'Отмена возврата Apple IAP: {product_id}',
payment_method=PaymentMethod.APPLE_IAP,
commit=False,
)
apple_txn.status = 'verified'
apple_txn.refunded_at = None
await db.commit()
logger.info(
'Apple REFUND_REVERSED processed -- balance re-credited',
transaction_id=lookup_id,
amount_kopeks=apple_txn.amount_kopeks,
user_id=user.id,
)
except Exception as e:
logger.error('Error handling Apple REFUND_REVERSED', error=e, exc_info=True)
async def _handle_apple_consumption_request(self, notification: dict, apple_service) -> None:
"""Handle CONSUMPTION_REQUEST -- send consumption info to Apple."""
try:
data = notification.get('data', {})
signed_txn_info = data.get('signedTransactionInfo')
if not signed_txn_info:
logger.warning('No signedTransactionInfo in CONSUMPTION_REQUEST')
return
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
if not txn_info:
logger.warning('Failed to verify CONSUMPTION_REQUEST transaction info')
return
apple_txn_id = str(txn_info.get('transactionId') or '')
environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT)
from app.database.crud.apple_iap import get_apple_transaction_by_transaction_id
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
apple_txn = await get_apple_transaction_by_transaction_id(db, apple_txn_id)
# Determine if balance was consumed (spent on subscriptions)
# consumptionStatus: 0 = undeclared, 1 = not consumed, 2 = partially consumed, 3 = fully consumed
consumption_status = 0
if apple_txn and apple_txn.status == 'verified':
consumption_status = 3 # Balance was credited and likely spent
# customerConsented must be false -- we cannot prompt the user
# in a server-to-server webhook. Apple accepts the response
# regardless, but the consumption data weight may be lower.
await apple_service.send_consumption_info(
transaction_id=apple_txn_id,
customer_consented=False,
consumption_status=consumption_status,
delivery_status=0, # 0 = delivered
platform=1, # 1 = Apple
environment=environment,
)
logger.info('Apple CONSUMPTION_REQUEST handled', transaction_id=apple_txn_id)
except Exception as e:
logger.error('Error handling Apple CONSUMPTION_REQUEST', error=e, exc_info=True)
+3
View File
@@ -175,6 +175,7 @@ async def _build_notification_preview_message(language: str, notification_type:
message = template.format( message = template.format(
end_date=(now - timedelta(days=1)).strftime('%d.%m.%Y %H:%M'), end_date=(now - timedelta(days=1)).strftime('%d.%m.%Y %H:%M'),
price=price_30_days, price=price_30_days,
tariff_label='',
) )
keyboard = InlineKeyboardMarkup( keyboard = InlineKeyboardMarkup(
inline_keyboard=[ inline_keyboard=[
@@ -213,6 +214,7 @@ async def _build_notification_preview_message(language: str, notification_type:
percent=percent, percent=percent,
expires_at=(now + timedelta(hours=valid_hours)).strftime('%d.%m.%Y %H:%M'), expires_at=(now + timedelta(hours=valid_hours)).strftime('%d.%m.%Y %H:%M'),
trigger_days=3, trigger_days=3,
tariff_label='',
) )
keyboard = InlineKeyboardMarkup( keyboard = InlineKeyboardMarkup(
inline_keyboard=[ inline_keyboard=[
@@ -258,6 +260,7 @@ async def _build_notification_preview_message(language: str, notification_type:
percent=percent, percent=percent,
trigger_days=trigger_days, trigger_days=trigger_days,
expires_at=(now + timedelta(hours=valid_hours)).strftime('%d.%m.%Y %H:%M'), expires_at=(now + timedelta(hours=valid_hours)).strftime('%d.%m.%Y %H:%M'),
tariff_label='',
) )
keyboard = InlineKeyboardMarkup( keyboard = InlineKeyboardMarkup(
inline_keyboard=[ inline_keyboard=[
+315
View File
@@ -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 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}\u20bd</b>\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 <b>Пополнение ограничено</b>\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 <b>Пополнение ограничено</b>\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 <b>Пополнение через {name}</b>\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')
+63 -8
View File
@@ -39,6 +39,7 @@ async def _create_aurapay_payment_and_respond(
db: AsyncSession, db: AsyncSession,
amount_kopeks: int, amount_kopeks: int,
edit_message: bool = False, edit_message: bool = False,
payment_method_type: str | None = None,
): ):
""" """
Common logic for creating AuraPay payment and sending response. Common logic for creating AuraPay payment and sending response.
@@ -61,6 +62,7 @@ async def _create_aurapay_payment_and_respond(
description=description, description=description,
email=getattr(db_user, 'email', None), email=getattr(db_user, 'email', None),
language=db_user.language, language=db_user.language,
payment_method_type=payment_method_type,
) )
if not result: if not result:
@@ -179,6 +181,11 @@ async def process_aurapay_payment_amount(
) )
return 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 state.clear()
await _create_aurapay_payment_and_respond( await _create_aurapay_payment_and_respond(
@@ -187,19 +194,30 @@ async def process_aurapay_payment_amount(
db=db, db=db,
amount_kopeks=amount_kopeks, amount_kopeks=amount_kopeks,
edit_message=False, edit_message=False,
payment_method_type=payment_method_type,
) )
@error_handler AURAPAY_PAYMENT_METHODS = {'aurapay', 'aurapay_sbp', 'aurapay_card'}
async def start_aurapay_topup(
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, callback: types.CallbackQuery,
db_user: User, db_user: User,
db: AsyncSession,
state: FSMContext, state: FSMContext,
payment_method: str,
): ):
""" """Common logic for starting AuraPay top-up (generic / SBP / card)."""
Start AuraPay top-up process - ask for amount.
"""
texts = get_texts(db_user.language) texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts) restriction_kb = _check_topup_restriction(db_user, texts)
@@ -213,11 +231,18 @@ async def start_aurapay_topup(
return return
await state.set_state(BalanceStates.waiting_for_amount) 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 min_amount = settings.AURAPAY_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.AURAPAY_MAX_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( keyboard = InlineKeyboardMarkup(
inline_keyboard=[ inline_keyboard=[
@@ -245,3 +270,33 @@ async def start_aurapay_topup(
parse_mode='HTML', parse_mode='HTML',
reply_markup=keyboard, 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')
+305
View File
@@ -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 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Нажмите кнопку ниже для перехода к оплате.\n'
'После подтверждения платежа баланс будет пополнен автоматически.',
).format(name=name, amount=f'{amount_rub:.2f}')
else:
response_text = texts.t(
'DONUT_PAYMENT_PROCESSING',
'\U0001f4b3 <b>Платёж создан через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\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 <b>Пополнение ограничено</b>\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 <b>Пополнение ограничено</b>\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 <b>Пополнение через {name}</b>\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')
+302
View File
@@ -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 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}\u20bd</b>\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 <b>Пополнение ограничено</b>\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 <b>Пополнение ограничено</b>\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 <b>Пополнение через {name}</b>\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')
+273
View File
@@ -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 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Нажмите кнопку ниже, чтобы открыть QR-код СБП и оплатить.\n'
'Баланс будет пополнен автоматически после подтверждения платежа.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
else:
response_text = texts.t(
'JUPITER_PAYMENT_PROCESSING',
'\U0001f4b3 <b>Платёж создан через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\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 <b>Пополнение ограничено</b>\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 <b>Пополнение ограничено</b>\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 <b>Пополнение через {name}</b>\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')
+284
View File
@@ -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 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\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 <b>Пополнение ограничено</b>\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 <b>Пополнение ограничено</b>\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 <b>Пополнение через {name}</b>\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')
+80 -2
View File
@@ -170,13 +170,48 @@ async def route_payment_by_method(
await process_overpay_payment_amount(message, db_user, db, amount_kopeks, state) await process_overpay_payment_amount(message, db_user, db, amount_kopeks, state)
return True return True
if payment_method == 'aurapay': if payment_method in ('aurapay', 'aurapay_sbp', 'aurapay_card'):
from .aurapay import process_aurapay_payment_amount from .aurapay import process_aurapay_payment_amount
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
await process_aurapay_payment_amount(message, db_user, db, amount_kopeks, state) await process_aurapay_payment_amount(message, db_user, db, amount_kopeks, state)
return True return True
if payment_method in ('etoplatezhi', 'etoplatezhi_sbp', 'etoplatezhi_card'):
from .etoplatezhi import process_etoplatezhi_payment_amount
async with AsyncSessionLocal() as db:
await process_etoplatezhi_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method in ('antilopay', 'antilopay_sbp', 'antilopay_card', 'antilopay_sberpay'):
from .antilopay import process_antilopay_payment_amount
async with AsyncSessionLocal() as db:
await process_antilopay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method in ('jupiter', 'jupiter_sbp'):
from .jupiter import process_jupiter_payment_amount
async with AsyncSessionLocal() as db:
await process_jupiter_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method in ('donut', 'donut_card', 'donut_sbp', 'donut_sbp_qr'):
from .donut import process_donut_payment_amount
async with AsyncSessionLocal() as db:
await process_donut_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method in ('lava', 'lava_card', 'lava_sbp'):
from .lava import process_lava_payment_amount
async with AsyncSessionLocal() as db:
await process_lava_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'riopay': if payment_method == 'riopay':
from .riopay import process_riopay_payment_amount from .riopay import process_riopay_payment_amount
@@ -768,9 +803,52 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_overpay_topup, F.data == 'topup_overpay') 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_topup, F.data == 'topup_aurapay')
dp.callback_query.register(start_aurapay_sbp_topup, F.data == 'topup_aurapay_sbp')
dp.callback_query.register(start_aurapay_card_topup, F.data == 'topup_aurapay_card')
from .etoplatezhi import start_etoplatezhi_card_topup, start_etoplatezhi_sbp_topup, start_etoplatezhi_topup
dp.callback_query.register(start_etoplatezhi_topup, F.data == 'topup_etoplatezhi')
dp.callback_query.register(start_etoplatezhi_sbp_topup, F.data == 'topup_etoplatezhi_sbp')
dp.callback_query.register(start_etoplatezhi_card_topup, F.data == 'topup_etoplatezhi_card')
from .antilopay import (
start_antilopay_card_topup,
start_antilopay_sberpay_topup,
start_antilopay_sbp_topup,
start_antilopay_topup,
)
dp.callback_query.register(start_antilopay_topup, F.data == 'topup_antilopay')
dp.callback_query.register(start_antilopay_sbp_topup, F.data == 'topup_antilopay_sbp')
dp.callback_query.register(start_antilopay_card_topup, F.data == 'topup_antilopay_card')
dp.callback_query.register(start_antilopay_sberpay_topup, F.data == 'topup_antilopay_sberpay')
from .jupiter import start_jupiter_sbp_topup, start_jupiter_topup
dp.callback_query.register(start_jupiter_topup, F.data == 'topup_jupiter')
dp.callback_query.register(start_jupiter_sbp_topup, F.data == 'topup_jupiter_sbp')
from .donut import (
start_donut_card_topup,
start_donut_sbp_qr_topup,
start_donut_sbp_topup,
start_donut_topup,
)
dp.callback_query.register(start_donut_topup, F.data == 'topup_donut')
dp.callback_query.register(start_donut_card_topup, F.data == 'topup_donut_card')
dp.callback_query.register(start_donut_sbp_topup, F.data == 'topup_donut_sbp')
dp.callback_query.register(start_donut_sbp_qr_topup, F.data == 'topup_donut_sbp_qr')
from .lava import start_lava_card_topup, start_lava_sbp_topup, start_lava_topup
dp.callback_query.register(start_lava_topup, F.data == 'topup_lava')
dp.callback_query.register(start_lava_card_topup, F.data == 'topup_lava_card')
dp.callback_query.register(start_lava_sbp_topup, F.data == 'topup_lava_sbp')
from .mulenpay import check_mulenpay_payment_status from .mulenpay import check_mulenpay_payment_status
+47 -1
View File
@@ -49,7 +49,11 @@ from app.services.pinned_message_service import (
get_active_pinned_message, get_active_pinned_message,
) )
from app.services.privacy_policy_service import PrivacyPolicyService 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.subscription_service import SubscriptionService
from app.services.support_settings_service import SupportSettingsService from app.services.support_settings_service import SupportSettingsService
from app.services.web_auth_service import WEB_AUTH_TOKEN_MIN_LENGTH, link_web_auth_token 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: if not result.success:
return None 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': if result.bonus_type == 'balance':
amount_text = texts.format_price(result.balance_kopeks) amount_text = texts.format_price(result.balance_kopeks)
return texts.CAMPAIGN_BONUS_BALANCE.format( 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, start_parameter=campaign.start_parameter,
) )
await state.update_data(campaign_id=campaign.id) 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: if campaign.partner_user_id:
await state.update_data(referrer_id=campaign.partner_user_id) await state.update_data(referrer_id=campaign.partner_user_id)
logger.info( logger.info(
@@ -2416,6 +2448,20 @@ async def required_sub_channel_check(
campaign_id=campaign.id, campaign_id=campaign.id,
partner_user_id=campaign.partner_user_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: else:
state_data['referral_code'] = pending_start_payload state_data['referral_code'] = pending_start_payload
logger.info( logger.info(
+27 -1
View File
@@ -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]: def normalize_app(app: dict[str, Any]) -> dict[str, Any]:
"""Normalize Remnawave app dict to a unified format with blocks.""" """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 { return {
'id': app.get('id', app.get('name', 'unknown')), 'id': app.get('id', app.get('name', 'unknown')),
'name': app.get('name', ''), 'name': app.get('name', ''),
'isFeatured': app.get('featured', app.get('isFeatured', False)), 'isFeatured': app.get('featured', app.get('isFeatured', False)),
'urlScheme': app.get('urlScheme', ''), 'urlScheme': url_scheme,
'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False), 'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False),
'blocks': app.get('blocks', []), 'blocks': app.get('blocks', []),
'_raw': app, '_raw': app,
@@ -132,6 +132,16 @@ def _build_subscription_detail_keyboard(sub_id: int, sub=None) -> types.InlineKe
if is_inactive: if is_inactive:
buttons.append([types.InlineKeyboardButton(text='🗑 Удалить подписку', callback_data=f'sub_del:{sub_id}')]) 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')]) buttons.append([types.InlineKeyboardButton(text='◀️ К списку подписок', callback_data='my_subscriptions')])
return types.InlineKeyboardMarkup(inline_keyboard=buttons) return types.InlineKeyboardMarkup(inline_keyboard=buttons)
+11
View File
@@ -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_change_devices_menu, F.data.startswith('change_devices_menu:'))
dp.callback_query.register(handle_device_management_menu, F.data.startswith('device_management:')) 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(show_trial_offer, F.data == 'menu_trial')
dp.callback_query.register(activate_trial, F.data == 'trial_activate') dp.callback_query.register(activate_trial, F.data == 'trial_activate')
+350
View File
@@ -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',
(
'⚠️ <b>Перевыпуск подписки</b>\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',
(
'✅ <b>Подписка перевыпущена!</b>\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',
(
'⚠️ <b>Перевыпуск подписки</b>\n\n'
'Это действие:\n'
'• Сгенерирует новую ссылку подключения\n'
'• Сбросит все подключённые устройства\n'
'• Старая ссылка перестанет работать\n\n'
'Продолжить?'
),
),
reply_markup=_build_revoke_confirm_keyboard(db_user.language, multi_tariff=True),
parse_mode='HTML',
)
+274 -8
View File
@@ -8,6 +8,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import PERIOD_PRICES, settings from app.config import PERIOD_PRICES, settings
from app.database.models import User 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.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts from app.localization.texts import get_texts
from app.utils.miniapp_buttons import build_miniapp_or_callback_button from app.utils.miniapp_buttons import build_miniapp_or_callback_button
@@ -1844,7 +1850,35 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
) )
has_direct_payment_methods = True 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() aurapay_name = settings.get_aurapay_display_name()
keyboard.append( keyboard.append(
[ [
@@ -1856,6 +1890,212 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
) )
has_direct_payment_methods = True has_direct_payment_methods = True
if settings.is_etoplatezhi_sbp_enabled():
sbp_name = settings.get_etoplatezhi_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ETOPLATEZHI_SBP', f'📱 {sbp_name}'),
callback_data=_build_callback('etoplatezhi_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_etoplatezhi_card_enabled():
card_name = settings.get_etoplatezhi_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ETOPLATEZHI_CARD', f'💳 {card_name}'),
callback_data=_build_callback('etoplatezhi_card'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_etoplatezhi_enabled()
and not settings.is_etoplatezhi_sbp_enabled()
and not settings.is_etoplatezhi_card_enabled()
):
etoplatezhi_name = settings.get_etoplatezhi_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ETOPLATEZHI', f'💳 {etoplatezhi_name}'),
callback_data=_build_callback('etoplatezhi'),
)
]
)
has_direct_payment_methods = True
if settings.is_antilopay_sbp_enabled():
sbp_name = settings.get_antilopay_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ANTILOPAY_SBP', f'📱 {sbp_name}'),
callback_data=_build_callback('antilopay_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_antilopay_card_enabled():
card_name = settings.get_antilopay_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ANTILOPAY_CARD', f'💳 {card_name}'),
callback_data=_build_callback('antilopay_card'),
)
]
)
has_direct_payment_methods = True
if settings.is_antilopay_sberpay_enabled():
sberpay_name = settings.get_antilopay_sberpay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ANTILOPAY_SBERPAY', f'💳 {sberpay_name}'),
callback_data=_build_callback('antilopay_sberpay'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_antilopay_enabled()
and not settings.is_antilopay_sbp_enabled()
and not settings.is_antilopay_card_enabled()
and not settings.is_antilopay_sberpay_enabled()
):
antilopay_name = settings.get_antilopay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ANTILOPAY', f'💳 {antilopay_name}'),
callback_data=_build_callback('antilopay'),
)
]
)
has_direct_payment_methods = True
if settings.is_jupiter_sbp_enabled():
jupiter_sbp_name = settings.get_jupiter_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_JUPITER_SBP', f'📱 {jupiter_sbp_name}'),
callback_data=_build_callback('jupiter_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_jupiter_enabled() and not settings.is_jupiter_sbp_enabled():
jupiter_name = settings.get_jupiter_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_JUPITER', f'🪐 {jupiter_name}'),
callback_data=_build_callback('jupiter'),
)
]
)
has_direct_payment_methods = True
if settings.is_donut_card_enabled():
donut_card_name = settings.get_donut_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_DONUT_CARD', f'💳 {donut_card_name}'),
callback_data=_build_callback('donut_card'),
)
]
)
has_direct_payment_methods = True
if settings.is_donut_sbp_enabled():
donut_sbp_name = settings.get_donut_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_DONUT_SBP', f'📱 {donut_sbp_name}'),
callback_data=_build_callback('donut_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_donut_sbp_qr_enabled():
donut_qr_name = settings.get_donut_sbp_qr_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_DONUT_SBP_QR', f'🏦 {donut_qr_name}'),
callback_data=_build_callback('donut_sbp_qr'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_donut_enabled()
and not settings.is_donut_card_enabled()
and not settings.is_donut_sbp_enabled()
and not settings.is_donut_sbp_qr_enabled()
):
donut_name = settings.get_donut_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_DONUT', f'🍩 {donut_name}'),
callback_data=_build_callback('donut'),
)
]
)
has_direct_payment_methods = True
if settings.is_lava_card_enabled():
lava_card_name = settings.get_lava_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_LAVA_CARD', f'💳 {lava_card_name}'),
callback_data=_build_callback('lava_card'),
)
]
)
has_direct_payment_methods = True
if settings.is_lava_sbp_enabled():
lava_sbp_name = settings.get_lava_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_LAVA_SBP', f'📱 {lava_sbp_name}'),
callback_data=_build_callback('lava_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_lava_enabled() and not settings.is_lava_card_enabled() and not settings.is_lava_sbp_enabled():
lava_name = settings.get_lava_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_LAVA', f'🌋 {lava_name}'),
callback_data=_build_callback('lava'),
)
]
)
has_direct_payment_methods = True
if settings.is_support_topup_enabled(): if settings.is_support_topup_enabled():
keyboard.append( keyboard.append(
[ [
@@ -2554,9 +2794,6 @@ def get_device_selection_keyboard(
platforms: list[dict] | None = None, platforms: list[dict] | None = None,
sub_id: int | None = None, sub_id: int | None = None,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
from app.config import settings
from app.handlers.subscription.common import get_localized_value
texts = get_texts(language) texts = get_texts(language)
back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription' back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription'
@@ -2609,8 +2846,6 @@ def get_connection_guide_keyboard(
has_other_apps: bool = False, has_other_apps: bool = False,
sub_id: int | None = None, sub_id: int | None = None,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
from app.handlers.subscription.common import create_deep_link, get_localized_value, resolve_button_url
texts = get_texts(language) texts = get_texts(language)
back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription' back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription'
@@ -2623,6 +2858,10 @@ def get_connection_guide_keyboard(
if not isinstance(btn, dict): if not isinstance(btn, dict):
continue continue
btn_type = btn.get('type', '') 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', {}) btn_text = btn.get('text', {})
if isinstance(btn_text, dict): if isinstance(btn_text, dict):
btn_text = get_localized_value(btn_text, language) btn_text = get_localized_value(btn_text, language)
@@ -2644,9 +2883,26 @@ def get_connection_guide_keyboard(
] ]
) )
elif btn_type == 'subscriptionLink': elif btn_type == 'subscriptionLink':
# First try to resolve the button's URL template
url = resolved_url or resolve_button_url(btn_url, subscription_url) 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: if final_url:
keyboard.append( keyboard.append(
[ [
@@ -2976,6 +3232,16 @@ def get_updated_subscription_settings_keyboard(
] ]
) )
if settings.is_subscription_revoke_enabled():
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('SUBSCRIPTION_REVOKE_BTN', '🔄 Перевыпустить подписку'),
callback_data='subscription_revoke',
)
]
)
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]) keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')])
return InlineKeyboardMarkup(inline_keyboard=keyboard) return InlineKeyboardMarkup(inline_keyboard=keyboard)
+11 -1
View File
@@ -1758,5 +1758,15 @@
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Torrent detected</b>\n\nTorrent traffic was detected on your connection{tariff_label}. Using torrents may result in subscription restrictions.", "WEBHOOK_TORRENT_DETECTED": "🚫 <b>Torrent detected</b>\n\nTorrent traffic was detected on your connection{tariff_label}. Using torrents may result in subscription restrictions.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Close", "WEBHOOK_CLOSE_BUTTON": "✖️ Close",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Traffic Warning</b>\n\nUsed: {used:.1f} / {limit} GB ({percent:.0f}%)\n\nYour traffic limit is almost reached.", "TRAFFIC_WARNING_ALERT": "⚠️ <b>Traffic Warning</b>\n\nUsed: {used:.1f} / {limit} GB ({percent:.0f}%)\n\nYour traffic limit is almost reached.",
"LOW_BALANCE_ALERT": "⚠️ <b>Low Balance</b>\n\nYour balance: {balance} ₽\nNotification threshold: {threshold} ₽\n\nTop up your balance to ensure automatic subscription renewal." "LOW_BALANCE_ALERT": "⚠️ <b>Low Balance</b>\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": "⚠️ <b>Reissue Subscription</b>\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": "✅ <b>Subscription reissued!</b>\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"
} }
+11 -1
View File
@@ -1779,5 +1779,15 @@
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>تورنت شناسایی شد</b>\n\nترافیک تورنت در اتصال{tariff_label} شما شناسایی شد. استفاده از تورنت ممکن است منجر به محدودیت اشتراک شود.", "WEBHOOK_TORRENT_DETECTED": "🚫 <b>تورنت شناسایی شد</b>\n\nترافیک تورنت در اتصال{tariff_label} شما شناسایی شد. استفاده از تورنت ممکن است منجر به محدودیت اشتراک شود.",
"WEBHOOK_CLOSE_BUTTON": "✖️ بستن", "WEBHOOK_CLOSE_BUTTON": "✖️ بستن",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>هشدار ترافیک</b>\n\nاستفاده شده: {used:.1f} / {limit} گیگابایت ({percent:.0f}%)\n\nحد ترافیک شما تقریباً تمام شده است.", "TRAFFIC_WARNING_ALERT": "⚠️ <b>هشدار ترافیک</b>\n\nاستفاده شده: {used:.1f} / {limit} گیگابایت ({percent:.0f}%)\n\nحد ترافیک شما تقریباً تمام شده است.",
"LOW_BALANCE_ALERT": "⚠️ <b>موجودی کم</b>\n\nموجودی شما: {balance} ₽\nآستانه اطلاع‌رسانی: {threshold} ₽\n\nموجودی خود را شارژ کنید تا تمدید خودکار اشتراک با موفقیت انجام شود." "LOW_BALANCE_ALERT": "⚠️ <b>موجودی کم</b>\n\nموجودی شما: {balance} ₽\nآستانه اطلاع‌رسانی: {threshold} ₽\n\nموجودی خود را شارژ کنید تا تمدید خودکار اشتراک با موفقیت انجام شود.",
"SUBSCRIPTION_REVOKE_BTN": "🔄 صدور مجدد اشتراک",
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ صدور مجدد اشتراک",
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>صدور مجدد اشتراک</b>\n\nاین عمل:\n• لینک اتصال جدیدی تولید می‌کند\n• تمام دستگاه‌های متصل را قطع می‌کند\n• لینک قدیمی دیگر کار نخواهد کرد\n\nادامه می‌دهید؟",
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ تأیید",
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>اشتراک مجدداً صادر شد!</b>\n\nلینک اتصال جدید آماده است. لینک قدیمی دیگر معتبر نیست.\n\nتمام دستگاه‌ها قطع شدند.",
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ صدور مجدد {minutes} دقیقه و {seconds} ثانیه دیگر در دسترس خواهد بود.",
"SUBSCRIPTION_REVOKE_DISABLED": "صدور مجدد اشتراک در دسترس نیست",
"SUBSCRIPTION_REVOKE_ERROR": "❌ خطا در صدور مجدد اشتراک. لطفاً بعداً دوباره امتحان کنید.",
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 اتصال"
} }
+11 -1
View File
@@ -1779,5 +1779,15 @@
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Обнаружен торрент</b>\n\nВ вашем подключении{tariff_label} обнаружен торрент-трафик. Использование торрентов может привести к ограничению подписки.", "WEBHOOK_TORRENT_DETECTED": "🚫 <b>Обнаружен торрент</b>\n\nВ вашем подключении{tariff_label} обнаружен торрент-трафик. Использование торрентов может привести к ограничению подписки.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть", "WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Предупреждение о трафике</b>\n\nИспользовано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш лимит трафика почти исчерпан.", "TRAFFIC_WARNING_ALERT": "⚠️ <b>Предупреждение о трафике</b>\n\nИспользовано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш лимит трафика почти исчерпан.",
"LOW_BALANCE_ALERT": "⚠️ <b>Низкий баланс</b>\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно." "LOW_BALANCE_ALERT": "⚠️ <b>Низкий баланс</b>\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно.",
"SUBSCRIPTION_REVOKE_BTN": "🔄 Перевыпустить подписку",
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ Перевыпуск подписки",
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>Перевыпуск подписки</b>\n\nЭто действие:\n• Сгенерирует новую ссылку подключения\n• Сбросит все подключённые устройства\n• Старая ссылка перестанет работать\n\nПродолжить?",
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Подтвердить",
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>Подписка перевыпущена!</b>\n\nНовая ссылка подключения готова. Старая ссылка больше не действительна.\n\nВсе устройства были отключены.",
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.",
"SUBSCRIPTION_REVOKE_DISABLED": "Перевыпуск подписки недоступен",
"SUBSCRIPTION_REVOKE_ERROR": "❌ Ошибка при перевыпуске подписки. Попробуйте позже.",
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Подключиться"
} }
+11 -1
View File
@@ -1650,5 +1650,15 @@
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Виявлено торент</b>\n\nУ вашому підключенні{tariff_label} виявлено торент-трафік. Використання торентів може призвести до обмеження підписки.", "WEBHOOK_TORRENT_DETECTED": "🚫 <b>Виявлено торент</b>\n\nУ вашому підключенні{tariff_label} виявлено торент-трафік. Використання торентів може призвести до обмеження підписки.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрити", "WEBHOOK_CLOSE_BUTTON": "✖️ Закрити",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Попередження про трафік</b>\n\nВикористано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш ліміт трафіку майже вичерпаний.", "TRAFFIC_WARNING_ALERT": "⚠️ <b>Попередження про трафік</b>\n\nВикористано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш ліміт трафіку майже вичерпаний.",
"LOW_BALANCE_ALERT": "⚠️ <b>Низький баланс</b>\n\nВаш баланс: {balance} ₽\nПоріг сповіщення: {threshold} ₽\n\nПоповніть баланс, щоб автопродовження підписки пройшло успішно." "LOW_BALANCE_ALERT": "⚠️ <b>Низький баланс</b>\n\nВаш баланс: {balance} ₽\nПоріг сповіщення: {threshold} ₽\n\nПоповніть баланс, щоб автопродовження підписки пройшло успішно.",
"SUBSCRIPTION_REVOKE_BTN": "🔄 Перевипустити підписку",
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ Перевипуск підписки",
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>Перевипуск підписки</b>\n\nЦя дія:\n• Згенерує нове посилання підключення\n• Скине всі підключені пристрої\n• Старе посилання перестане працювати\n\nПродовжити?",
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Підтвердити",
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>Підписку перевипущено!</b>\n\nНове посилання підключення готове. Старе посилання більше не дійсне.\n\nВсі пристрої були відключені.",
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Перевипуск буде доступний через {minutes} хв. {seconds} сек.",
"SUBSCRIPTION_REVOKE_DISABLED": "Перевипуск підписки недоступний",
"SUBSCRIPTION_REVOKE_ERROR": "❌ Помилка при перевипуску підписки. Спробуйте пізніше.",
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Підключитися"
} }
+11 -1
View File
@@ -1648,5 +1648,15 @@
"BALANCE_TOPPED_UP_CART_SUFFICIENT": "✅ 余额已充值 {amount}\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n余额足够完成订购。", "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}", "BALANCE_TOPPED_UP_CART_INSUFFICIENT": "✅ 余额已充值 {amount}\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n还差:{missing}",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>流量警告</b>\n\n已使用:{used:.1f} / {limit} GB ({percent:.0f}%)\n\n您的流量限制即将用完。", "TRAFFIC_WARNING_ALERT": "⚠️ <b>流量警告</b>\n\n已使用:{used:.1f} / {limit} GB ({percent:.0f}%)\n\n您的流量限制即将用完。",
"LOW_BALANCE_ALERT": "⚠️ <b>余额不足</b>\n\n您的余额:{balance} ₽\n通知阈值:{threshold} ₽\n\n请充值以确保订阅自动续费成功。" "LOW_BALANCE_ALERT": "⚠️ <b>余额不足</b>\n\n您的余额:{balance} ₽\n通知阈值:{threshold} ₽\n\n请充值以确保订阅自动续费成功。",
"SUBSCRIPTION_REVOKE_BTN": "🔄 重新签发订阅",
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ 重新签发订阅",
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>重新签发订阅</b>\n\n此操作将:\n• 生成新的连接链接\n• 断开所有已连接的设备\n• 旧链接将失效\n\n是否继续?",
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ 确认",
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>订阅已重新签发!</b>\n\n新的连接链接已准备就绪。旧链接已失效。\n\n所有设备已断开连接。",
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ 重新签发将在 {minutes} 分 {seconds} 秒后可用。",
"SUBSCRIPTION_REVOKE_DISABLED": "订阅重新签发不可用",
"SUBSCRIPTION_REVOKE_ERROR": "❌ 重新签发订阅时出错。请稍后重试。",
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 连接"
} }
+246
View File
@@ -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()
+2 -1
View File
@@ -197,8 +197,9 @@ class AuraPayService:
return False return False
# Сортируем ключи по алфавиту и конкатенируем значения # Сортируем ключи по алфавиту и конкатенируем значения
# None → '' (PHP implode() converts null to empty string, not "None")
sorted_keys = sorted(payload.keys()) 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( expected = hmac.new(
self.secret_key.encode('utf-8'), self.secret_key.encode('utf-8'),
+5
View File
@@ -31,6 +31,7 @@ from app.database.models import (
AdminRole, AdminRole,
AdvertisingCampaign, AdvertisingCampaign,
AdvertisingCampaignRegistration, AdvertisingCampaignRegistration,
AppleTransaction,
AuraPayPayment, AuraPayPayment,
BroadcastHistory, BroadcastHistory,
ButtonClickLog, ButtonClickLog,
@@ -204,6 +205,7 @@ class BackupService:
RollyPayPayment, RollyPayPayment,
OverpayPayment, OverpayPayment,
AuraPayPayment, AuraPayPayment,
AppleTransaction,
SavedPaymentMethod, SavedPaymentMethod,
# --- Settings/content --- # --- Settings/content ---
PaymentMethodConfig, PaymentMethodConfig,
@@ -1515,6 +1517,9 @@ class BackupService:
'rollypay_payments', 'rollypay_payments',
'overpay_payments', 'overpay_payments',
'aurapay_payments', 'aurapay_payments',
'etoplatezhi_payments',
'antilopay_payments',
'apple_transactions',
'saved_payment_methods', 'saved_payment_methods',
# --- Content/config --- # --- Content/config ---
'pinned_messages', 'pinned_messages',
+266
View File
@@ -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()
+202
View File
@@ -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()
+268
View File
@@ -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()
+234
View File
@@ -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()
+22 -5
View File
@@ -1044,7 +1044,7 @@ class MonitoringService:
# Day 1 reminder # Day 1 reminder
if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2: if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2:
if not await notification_sent(db, user.id, subscription.id, 'expired_1d'): 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: if success:
await record_notification(db, user.id, subscription.id, 'expired_1d') await record_notification(db, user.id, subscription.id, 'expired_1d')
sent_day1 += 1 sent_day1 += 1
@@ -1811,12 +1811,29 @@ class MonitoringService:
) )
return False 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: try:
texts = get_texts(user.language) texts = get_texts(user.language)
tariff = getattr(subscription, 'tariff', None)
tariff_label = '' tariff_label = ''
if settings.is_multi_tariff_enabled() and hasattr(subscription, 'tariff') and subscription.tariff: if settings.is_multi_tariff_enabled() and tariff:
tariff_label = f' «{subscription.tariff.name}»' 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( template = texts.get(
'SUBSCRIPTION_EXPIRED_1D', 'SUBSCRIPTION_EXPIRED_1D',
( (
@@ -1826,7 +1843,7 @@ class MonitoringService:
) )
message = template.format( message = template.format(
end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'), 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, tariff_label=tariff_label,
) )
+531
View File
@@ -0,0 +1,531 @@
"""Mixin для интеграции с Antilopay (lk.antilopay.com)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from importlib import import_module
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.antilopay_service import antilopay_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг статусов Antilopay -> internal
ANTILOPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
'PENDING': ('pending', False),
'SUCCESS': ('success', True),
'FAIL': ('failed', False),
'CANCEL': ('cancelled', False),
'EXPIRED': ('expired', False),
'CHARGEBACK': ('chargeback', False),
'REVERSED': ('reversed', False),
}
class AntilopayPaymentMixin:
"""Mixin для работы с платежами Antilopay."""
async def create_antilopay_payment(
self,
db: AsyncSession,
*,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
payment_method_type: str | None = None,
return_url: str | None = None,
) -> dict[str, Any] | None:
"""
Создает платеж Antilopay.
Returns:
Словарь с данными платежа или None при ошибке
"""
if not settings.is_antilopay_enabled():
logger.error('Antilopay не настроен')
return None
# Валидация лимитов
if amount_kopeks < settings.ANTILOPAY_MIN_AMOUNT_KOPEKS:
logger.warning(
'Antilopay: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
ANTILOPAY_MIN_AMOUNT_KOPEKS=settings.ANTILOPAY_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.ANTILOPAY_MAX_AMOUNT_KOPEKS:
logger.warning(
'Antilopay: сумма больше максимальной',
amount_kopeks=amount_kopeks,
ANTILOPAY_MAX_AMOUNT_KOPEKS=settings.ANTILOPAY_MAX_AMOUNT_KOPEKS,
)
return None
# Получаем telegram_id пользователя для order_id
payment_module = import_module('app.services.payment_service')
if user_id is not None:
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
user = None
tg_id = 'guest'
# Генерируем уникальный order_id с telegram_id для удобного поиска
order_id = f'alp{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.ANTILOPAY_CURRENCY
# Метаданные
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
}
try:
# Определяем prefer_methods по типу подметода
prefer_methods: list[str] | None = None
if payment_method_type == 'sbp':
prefer_methods = ['SBP']
elif payment_method_type == 'card':
prefer_methods = ['CARD_RU']
elif payment_method_type == 'sberpay':
prefer_methods = ['SBER_PAY']
# Формируем success/fail URL
result_url = return_url or settings.ANTILOPAY_RETURN_URL
# merchant_extra — строка до 255 символов для callback
merchant_extra = order_id
# Создаем платеж через API
api_result = await antilopay_service.create_payment(
amount_rubles=amount_rubles,
order_id=order_id,
product_name=settings.ANTILOPAY_PRODUCT_NAME,
product_type=settings.ANTILOPAY_PRODUCT_TYPE,
description=description,
customer_email=email,
prefer_methods=prefer_methods,
success_url=result_url,
fail_url=result_url,
merchant_extra=merchant_extra,
)
payment_id = api_result.get('payment_id')
payment_url = api_result.get('payment_url')
logger.info(
'Antilopay: получен ответ API',
order_id=order_id,
payment_id=payment_id,
payment_url=payment_url,
)
lifetime = settings.ANTILOPAY_PAYMENT_LIFETIME_MINUTES
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
# Сохраняем в БД
antilopay_crud = import_module('app.database.crud.antilopay')
local_payment = await antilopay_crud.create_antilopay_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method_type,
antilopay_payment_id=payment_id,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'Antilopay: создан платеж',
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
)
return {
'order_id': order_id,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'payment_url': payment_url,
'payment_id': payment_id,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('Antilopay: ошибка создания платежа', error=e)
return None
async def process_antilopay_callback(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""
Обрабатывает callback от Antilopay.
Подпись проверяется в webserver/payments.py до вызова этого метода.
Args:
db: Сессия БД
payload: JSON тело callback (signature проверена в webserver)
Returns:
True если платеж успешно обработан
"""
try:
callback_type = payload.get('type')
if callback_type != 'payment':
logger.info('Antilopay callback: неизвестный тип', callback_type=callback_type)
return True # Не наш тип — не ошибка
antilopay_payment_id = payload.get('payment_id')
antilopay_status = payload.get('status')
our_order_id = payload.get('order_id')
if not our_order_id or not antilopay_status:
logger.warning('Antilopay callback: отсутствуют обязательные поля', payload=payload)
return False
# Определяем is_paid по статусу
is_confirmed = antilopay_status == 'SUCCESS'
# Ищем платеж по order_id
antilopay_crud = import_module('app.database.crud.antilopay')
payment = await antilopay_crud.get_antilopay_payment_by_order_id(db, our_order_id)
if not payment:
logger.warning(
'Antilopay callback: платеж не найден',
order_id=our_order_id,
)
return False
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await antilopay_crud.get_antilopay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Antilopay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Проверка дублирования (re-check from locked row)
if payment.is_paid:
logger.info('Antilopay callback: платеж уже обработан', order_id=payment.order_id)
return True
# Маппинг статуса
status_info = ANTILOPAY_STATUS_MAP.get(antilopay_status, ('pending', False))
internal_status, is_paid = status_info
# Если статус SUCCESS, принудительно считаем оплаченным
if is_confirmed:
is_paid = True
internal_status = 'success'
callback_payload = {
'antilopay_payment_id': antilopay_payment_id,
'status': antilopay_status,
'amount': payload.get('amount'),
'original_amount': payload.get('original_amount'),
'fee': payload.get('fee'),
'currency': payload.get('currency'),
'pay_method': payload.get('pay_method'),
'pay_data': payload.get('pay_data'),
'customer': payload.get('customer'),
'merchant_extra': payload.get('merchant_extra'),
}
# Проверка суммы ДО обновления статуса
if is_paid:
original_amount = payload.get('original_amount')
if original_amount is not None:
# original_amount в РУБЛЯХ (float), конвертируем в копейки
received_kopeks = round(float(original_amount) * 100)
if abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'Antilopay amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await antilopay_crud.update_antilopay_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
callback_payload=callback_payload,
)
return False
# Финализируем платеж если оплачен — без промежуточного commit
if is_paid:
# Inline field assignments to keep FOR UPDATE lock intact
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.antilopay_payment_id = str(antilopay_payment_id) if antilopay_payment_id else None
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_antilopay_payment(db, payment, trigger='webhook')
# Для не-success статусов можно безопасно коммитить
payment = await antilopay_crud.update_antilopay_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('Antilopay callback: ошибка обработки', error=e)
return False
async def _finalize_antilopay_payment(
self,
db: AsyncSession,
payment: Any,
*,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE lock must be acquired by the caller before invoking this method.
"""
payment_module = import_module('app.services.payment_service')
antilopay_crud = import_module('app.database.crud.antilopay')
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'Antilopay платеж уже связан с транзакцией',
order_id=payment.order_id,
transaction_id=payment.transaction_id,
trigger=trigger,
)
return True
# Read fresh metadata AFTER lock to avoid stale data
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
# --- Guest purchase flow ---
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=payment.order_id,
provider_name='antilopay',
)
if guest_result is not None:
return True
# Ensure paid fields are set (idempotent — caller may have already set them)
if not payment.is_paid:
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
balance_already_credited = bool(metadata.get('balance_credited'))
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error('Пользователь не найден для Antilopay', user_id=payment.user_id)
return False
# Загружаем промогруппы в асинхронном контексте
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
for user_promo_group in getattr(user, 'user_promo_groups', []):
await db.refresh(user_promo_group, attribute_names=['promo_group'])
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
transaction_external_id = payment.order_id
# Проверяем дупликат транзакции
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.ANTILOPAY,
)
display_name = settings.get_antilopay_display_name()
description = f'Пополнение через {display_name}'
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.ANTILOPAY,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await antilopay_crud.link_antilopay_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info('Antilopay платеж уже зачислил баланс ранее', order_id=payment.order_id)
return True
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
# Emit deferred side-effects after atomic commit
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.ANTILOPAY,
external_id=transaction_external_id,
)
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения Antilopay', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, 'bot', None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления Antilopay', error=error)
if getattr(self, 'bot', None) and user.telegram_id:
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
'\u2705 <b>Пополнение успешно!</b>\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
+500
View File
@@ -0,0 +1,500 @@
"""Mixin для интеграции с Donut P2P (gw.donut.business)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from importlib import import_module
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.donut_service import donut_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг description при PayIn (Donut) <-> наш sub-method id
DONUT_METHOD_DESCRIPTIONS: dict[str | None, str] = {
None: 'CARD',
'card': 'CARD',
'sbp': 'SBP',
'sbp_qr': 'SBP_QR',
}
# Маппинг статусов Donut -> internal
DONUT_STATUS_MAP: dict[str, tuple[str, bool]] = {
'created': ('pending', False),
'processing': ('pending', False),
'success': ('success', True),
'cancelled': ('cancelled', False),
'error': ('error', False),
}
class DonutPaymentMixin:
"""Mixin для работы с платежами Donut."""
async def create_donut_payment(
self,
db: AsyncSession,
*,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
payment_method_type: str | None = None,
return_url: str | None = None,
) -> dict[str, Any] | None:
"""Создаёт платёж Donut."""
if not settings.is_donut_enabled():
logger.error('Donut не настроен')
return None
if amount_kopeks < settings.DONUT_MIN_AMOUNT_KOPEKS:
logger.warning(
'Donut: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
DONUT_MIN_AMOUNT_KOPEKS=settings.DONUT_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.DONUT_MAX_AMOUNT_KOPEKS:
logger.warning(
'Donut: сумма больше максимальной',
amount_kopeks=amount_kopeks,
DONUT_MAX_AMOUNT_KOPEKS=settings.DONUT_MAX_AMOUNT_KOPEKS,
)
return None
payment_module = import_module('app.services.payment_service')
if user_id is not None:
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
user = None
tg_id = 'guest'
order_id = f'dnt{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.DONUT_CURRENCY
method_key = (payment_method_type or '').lower() or None
method_description = DONUT_METHOD_DESCRIPTIONS.get(method_key, 'CARD')
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
'payment_method_type': method_key,
'donut_description': method_description,
}
try:
callback_url = self._build_donut_callback_url()
customer_id = str(tg_id) if tg_id != 'guest' else f'guest-{order_id[-6:]}'
actual_return_url = return_url or settings.DONUT_RETURN_URL
api_result = await donut_service.create_payment(
amount_rubles=amount_rubles,
order_id=order_id,
customer_id=customer_id,
method_description=method_description,
customer_email=email,
customer_phone=getattr(user, 'phone', None) if user else None,
callback_url=callback_url,
return_url=actual_return_url,
redirect=True,
)
transaction_id = api_result.get('transaction_id')
details = api_result.get('details') or {}
payment_url = api_result.get('redirect_url') or details.get('qrcode_url') or actual_return_url
logger.info(
'Donut: получен ответ API',
order_id=order_id,
transaction_id=transaction_id,
payment_url=payment_url,
method_description=method_description,
)
lifetime = settings.DONUT_PAYMENT_LIFETIME_MINUTES
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
donut_crud = import_module('app.database.crud.donut')
local_payment = await donut_crud.create_donut_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=method_key,
donut_transaction_id=str(transaction_id) if transaction_id else None,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'Donut: создан платеж',
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
)
return {
'order_id': order_id,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'payment_url': payment_url,
'payment_id': str(transaction_id) if transaction_id else None,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('Donut: ошибка создания платежа', error=e)
return None
@staticmethod
def _build_donut_callback_url() -> str | None:
"""Собирает абсолютный callback URL для Donut."""
webhook_path = settings.DONUT_WEBHOOK_PATH or '/donut-webhook'
base = (
getattr(settings, 'WEBHOOK_URL', None)
or getattr(settings, 'WEB_API_BASE_URL', None)
or getattr(settings, 'CABINET_URL', None)
)
if not base:
return None
return f'{base.rstrip("/")}{webhook_path if webhook_path.startswith("/") else "/" + webhook_path}'
async def process_donut_callback(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""Обрабатывает callback от Donut (подпись уже проверена в webserver)."""
try:
our_order_id = payload.get('order_id')
donut_transaction_id = payload.get('transaction_id')
status_obj = payload.get('status') or {}
donut_status = (status_obj.get('type') or '').strip().lower()
if not our_order_id or not donut_status:
logger.warning('Donut callback: отсутствуют обязательные поля', payload=payload)
return False
donut_crud = import_module('app.database.crud.donut')
payment = await donut_crud.get_donut_payment_by_order_id(db, our_order_id)
if not payment:
logger.warning('Donut callback: платеж не найден', order_id=our_order_id)
return False
locked = await donut_crud.get_donut_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Donut: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
if payment.is_paid:
logger.info('Donut callback: платеж уже обработан', order_id=payment.order_id)
return True
# Терминальные неуспешные статусы стики — провайдер не должен иметь возможность
# «починить» отклонённый/несовпавший платёж повторным callback'ом.
if payment.status in {'amount_mismatch', 'cancelled', 'error', 'expired'}:
logger.warning(
'Donut callback: платёж в терминальном неуспешном статусе, игнорируется',
order_id=payment.order_id,
current_status=payment.status,
incoming_status=donut_status,
)
return True
internal_status, is_paid = DONUT_STATUS_MAP.get(donut_status, ('pending', False))
callback_payload = {
'donut_transaction_id': donut_transaction_id,
'status_type': donut_status,
'amount': payload.get('amount'),
'recalculated': payload.get('recalculated'),
'timestamp': payload.get('timestamp'),
}
if is_paid:
amount_obj = payload.get('amount') or {}
received_value = amount_obj.get('value')
if received_value is not None:
try:
received_kopeks = round(float(received_value) * 100)
except (TypeError, ValueError):
received_kopeks = None
if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'Donut amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await donut_crud.update_donut_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
callback_payload=callback_payload,
)
return False
if is_paid:
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.donut_transaction_id = (
str(donut_transaction_id) if donut_transaction_id else payment.donut_transaction_id
)
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_donut_payment(db, payment, trigger='webhook')
payment = await donut_crud.update_donut_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('Donut callback: ошибка обработки', error=e)
return False
async def _finalize_donut_payment(
self,
db: AsyncSession,
payment: Any,
*,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE lock уже взят вызывающим.
"""
payment_module = import_module('app.services.payment_service')
donut_crud = import_module('app.database.crud.donut')
if payment.transaction_id:
logger.info(
'Donut платеж уже связан с транзакцией',
order_id=payment.order_id,
transaction_id=payment.transaction_id,
trigger=trigger,
)
return True
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=payment.order_id,
provider_name='donut',
)
if guest_result is not None:
return True
if not payment.is_paid:
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
balance_already_credited = bool(metadata.get('balance_credited'))
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error('Пользователь не найден для Donut', user_id=payment.user_id)
return False
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
for user_promo_group in getattr(user, 'user_promo_groups', []):
await db.refresh(user_promo_group, attribute_names=['promo_group'])
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
transaction_external_id = payment.order_id
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.DONUT,
)
display_name = settings.get_donut_display_name()
description = f'Пополнение через {display_name}'
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.DONUT,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await donut_crud.link_donut_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info('Donut платеж уже зачислил баланс ранее', order_id=payment.order_id)
return True
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.DONUT,
external_id=transaction_external_id,
)
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения Donut', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, 'bot', None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления Donut', error=error)
if getattr(self, 'bot', None) and user.telegram_id:
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
'✅ <b>Пополнение успешно!</b>\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
+518
View File
@@ -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 <b>Пополнение успешно!</b>\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
+494
View File
@@ -0,0 +1,494 @@
"""Mixin для интеграции с Jupiter (FPGate P2P v2.1, app.juppiter.tech)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from importlib import import_module
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.jupiter_service import jupiter_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг статусов Jupiter -> internal
JUPITER_STATUS_MAP: dict[str, tuple[str, bool]] = {
'success': ('success', True),
'processing': ('pending', False),
'cancelled': ('cancelled', False),
'declined': ('declined', False),
'error': ('error', False),
}
class JupiterPaymentMixin:
"""Mixin для работы с платежами Jupiter."""
async def create_jupiter_payment(
self,
db: AsyncSession,
*,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
payment_method_type: str | None = None,
return_url: str | None = None,
) -> dict[str, Any] | None:
"""Создаёт платёж Jupiter.
Параметр ``return_url`` принимается для совместимости сигнатуры с другими провайдерами
(cabinet/routes/balance.py одинаково вызывает все ``create_*_payment``), но фактически
не используется: спецификация Jupiter v2.1 имеет поле ``redirect`` зарезервированное
для будущего использования и не поддерживает return-URL семантику. Пользователь
видит QR-код СБП и подтверждает оплату в банковском приложении.
"""
if not settings.is_jupiter_enabled():
logger.error('Jupiter не настроен')
return None
if amount_kopeks < settings.JUPITER_MIN_AMOUNT_KOPEKS:
logger.warning(
'Jupiter: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
JUPITER_MIN_AMOUNT_KOPEKS=settings.JUPITER_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.JUPITER_MAX_AMOUNT_KOPEKS:
logger.warning(
'Jupiter: сумма больше максимальной',
amount_kopeks=amount_kopeks,
JUPITER_MAX_AMOUNT_KOPEKS=settings.JUPITER_MAX_AMOUNT_KOPEKS,
)
return None
payment_module = import_module('app.services.payment_service')
if user_id is not None:
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
user = None
tg_id = 'guest'
order_id = f'jup{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.JUPITER_CURRENCY
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
'payment_method_type': payment_method_type,
}
try:
callback_url = self._build_jupiter_callback_url()
customer_id = str(tg_id) if tg_id != 'guest' else f'guest-{order_id[-6:]}'
customer_name = (
getattr(user, 'first_name', None) or getattr(user, 'username', None) or settings.JUPITER_FALLBACK_NAME
)
api_result = await jupiter_service.create_payment(
amount_rubles=amount_rubles,
order_id=order_id,
customer_id=customer_id,
customer_email=email,
customer_name=customer_name,
callback_url=callback_url,
description=description[:255] if description else None,
)
transaction_id = api_result.get('transaction_id')
details = api_result.get('details') or {}
payment_url = details.get('qrcode_url') or details.get('url')
logger.info(
'Jupiter: получен ответ API',
order_id=order_id,
transaction_id=transaction_id,
payment_url=payment_url,
)
lifetime = settings.JUPITER_PAYMENT_LIFETIME_MINUTES
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
jupiter_crud = import_module('app.database.crud.jupiter')
local_payment = await jupiter_crud.create_jupiter_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method_type,
jupiter_transaction_id=str(transaction_id) if transaction_id else None,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'Jupiter: создан платеж',
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
)
return {
'order_id': order_id,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'payment_url': payment_url,
'payment_id': str(transaction_id) if transaction_id else None,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('Jupiter: ошибка создания платежа', error=e)
return None
@staticmethod
def _build_jupiter_callback_url() -> str | None:
"""Собирает абсолютный callback URL для Jupiter."""
webhook_path = settings.JUPITER_WEBHOOK_PATH or '/jupiter-webhook'
base = (
getattr(settings, 'WEBHOOK_URL', None)
or getattr(settings, 'WEB_API_BASE_URL', None)
or getattr(settings, 'CABINET_URL', None)
)
if not base:
return None
return f'{base.rstrip("/")}{webhook_path if webhook_path.startswith("/") else "/" + webhook_path}'
async def process_jupiter_callback(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""Обрабатывает callback от Jupiter (подпись уже проверена в webserver)."""
try:
our_order_id = payload.get('order_id')
jupiter_transaction_id = payload.get('transaction_id')
status_obj = payload.get('status') or {}
jupiter_status = (status_obj.get('type') or '').strip().lower()
if not our_order_id or not jupiter_status:
logger.warning('Jupiter callback: отсутствуют обязательные поля', payload=payload)
return False
jupiter_crud = import_module('app.database.crud.jupiter')
payment = await jupiter_crud.get_jupiter_payment_by_order_id(db, our_order_id)
if not payment:
logger.warning('Jupiter callback: платеж не найден', order_id=our_order_id)
return False
locked = await jupiter_crud.get_jupiter_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Jupiter: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
if payment.is_paid:
logger.info('Jupiter callback: платеж уже обработан', order_id=payment.order_id)
return True
# Терминальные неуспешные статусы стики — провайдер не должен иметь возможность
# «починить» отклонённый/несовпавший платёж повторным callback'ом.
if payment.status in {'amount_mismatch', 'cancelled', 'declined', 'error', 'expired'}:
logger.warning(
'Jupiter callback: платёж в терминальном неуспешном статусе, игнорируется',
order_id=payment.order_id,
current_status=payment.status,
incoming_status=jupiter_status,
)
return True
internal_status, is_paid = JUPITER_STATUS_MAP.get(jupiter_status, ('pending', False))
callback_payload = {
'jupiter_transaction_id': jupiter_transaction_id,
'status_type': jupiter_status,
'amount': payload.get('amount'),
'recalculated': payload.get('recalculated'),
'timestamp': payload.get('timestamp'),
}
# Сверяем сумму ДО обновления статуса
if is_paid:
amount_obj = payload.get('amount') or {}
received_value = amount_obj.get('value')
if received_value is not None:
try:
received_kopeks = round(float(received_value) * 100)
except (TypeError, ValueError):
received_kopeks = None
if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'Jupiter amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await jupiter_crud.update_jupiter_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
callback_payload=callback_payload,
)
return False
if is_paid:
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.jupiter_transaction_id = (
str(jupiter_transaction_id) if jupiter_transaction_id else payment.jupiter_transaction_id
)
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_jupiter_payment(db, payment, trigger='webhook')
payment = await jupiter_crud.update_jupiter_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('Jupiter callback: ошибка обработки', error=e)
return False
async def _finalize_jupiter_payment(
self,
db: AsyncSession,
payment: Any,
*,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE lock уже взят вызывающим.
"""
payment_module = import_module('app.services.payment_service')
jupiter_crud = import_module('app.database.crud.jupiter')
if payment.transaction_id:
logger.info(
'Jupiter платеж уже связан с транзакцией',
order_id=payment.order_id,
transaction_id=payment.transaction_id,
trigger=trigger,
)
return True
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=payment.order_id,
provider_name='jupiter',
)
if guest_result is not None:
return True
if not payment.is_paid:
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
balance_already_credited = bool(metadata.get('balance_credited'))
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error('Пользователь не найден для Jupiter', user_id=payment.user_id)
return False
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
for user_promo_group in getattr(user, 'user_promo_groups', []):
await db.refresh(user_promo_group, attribute_names=['promo_group'])
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
transaction_external_id = payment.order_id
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.JUPITER,
)
display_name = settings.get_jupiter_display_name()
description = f'Пополнение через {display_name}'
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.JUPITER,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await jupiter_crud.link_jupiter_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info('Jupiter платеж уже зачислил баланс ранее', order_id=payment.order_id)
return True
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.JUPITER,
external_id=transaction_external_id,
)
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения Jupiter', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, 'bot', None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления Jupiter', error=error)
if getattr(self, 'bot', None) and user.telegram_id:
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
'✅ <b>Пополнение успешно!</b>\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
+590
View File
@@ -0,0 +1,590 @@
"""Mixin для интеграции с Lava Business (gate.lava.ru)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from importlib import import_module
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.lava_service import lava_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг sub-method -> includeService для фильтрации методов на странице оплаты Lava
LAVA_INCLUDE_SERVICE_MAP: dict[str | None, list[str] | None] = {
None: None,
'card': ['card'],
'sbp': ['sbp'],
}
# Маппинг статусов Lava -> internal
LAVA_STATUS_MAP: dict[str, tuple[str, bool]] = {
'created': ('pending', False),
'pending': ('pending', False),
'processing': ('pending', False), # на случай промежуточного статуса
'success': ('success', True),
'cancel': ('cancelled', False),
'cancelled': ('cancelled', False),
'expired': ('expired', False),
'error': ('error', False),
'failed': ('failed', False),
}
class LavaPaymentMixin:
"""Mixin для работы с платежами Lava Business."""
async def create_lava_payment(
self,
db: AsyncSession,
*,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
payment_method_type: str | None = None,
return_url: str | None = None,
) -> dict[str, Any] | None:
"""Создаёт инвойс Lava."""
if not settings.is_lava_enabled():
logger.error('Lava не настроен')
return None
if amount_kopeks < settings.LAVA_MIN_AMOUNT_KOPEKS:
logger.warning(
'Lava: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
LAVA_MIN_AMOUNT_KOPEKS=settings.LAVA_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.LAVA_MAX_AMOUNT_KOPEKS:
logger.warning(
'Lava: сумма больше максимальной',
amount_kopeks=amount_kopeks,
LAVA_MAX_AMOUNT_KOPEKS=settings.LAVA_MAX_AMOUNT_KOPEKS,
)
return None
payment_module = import_module('app.services.payment_service')
if user_id is not None:
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
user = None
tg_id = 'guest'
# 32 hex char (128 бит) суффикс — order_id уникален даже при публичном tg_id
order_id = f'lava{tg_id}_{uuid.uuid4().hex}'
amount_rubles = amount_kopeks / 100
currency = settings.LAVA_CURRENCY
method_key = (payment_method_type or '').lower() or None
include_service = LAVA_INCLUDE_SERVICE_MAP.get(method_key)
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
'payment_method_type': method_key,
'email': email,
}
try:
hook_url = self._build_lava_hook_url()
if not hook_url:
logger.warning(
'Lava: hook_url не сконфигурирован — '
'платёж создаётся, но автоматическое подтверждение через webhook невозможно. '
'Установите WEBHOOK_URL / WEB_API_BASE_URL / CABINET_URL.'
)
actual_return_url = return_url or settings.LAVA_RETURN_URL
api_result = await lava_service.create_invoice(
amount_rubles=amount_rubles,
order_id=order_id,
hook_url=hook_url,
success_url=actual_return_url,
fail_url=actual_return_url,
expire_minutes=settings.LAVA_PAYMENT_LIFETIME_MINUTES,
comment=(description or '')[:255] or None,
custom_fields=str(user_id) if user_id is not None else None,
include_service=include_service,
)
data = (api_result.get('data') or api_result) if isinstance(api_result, dict) else {}
lava_invoice_id = data.get('id') or data.get('invoice_id')
payment_url = data.get('url') or data.get('payment_url')
expired_str = data.get('expired')
if not payment_url:
# Без URL у пользователя нет способа оплатить — это аномалия Lava API.
# Row не сохраняем, чтобы не плодить «зависшие» pending-инвойсы без реквизитов.
logger.error(
'Lava: ответ API без payment URL, инвойс не создан',
order_id=order_id,
lava_invoice_id=lava_invoice_id,
response_keys=list(data.keys()) if isinstance(data, dict) else None,
)
return None
logger.info(
'Lava: получен ответ API',
order_id=order_id,
lava_invoice_id=lava_invoice_id,
payment_url=payment_url,
)
lifetime = settings.LAVA_PAYMENT_LIFETIME_MINUTES
expires_at = self._parse_lava_expired(expired_str) or (datetime.now(UTC) + timedelta(minutes=lifetime))
lava_crud = import_module('app.database.crud.lava')
local_payment = await lava_crud.create_lava_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=method_key,
lava_invoice_id=str(lava_invoice_id) if lava_invoice_id else None,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'Lava: создан платеж',
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
)
return {
'order_id': order_id,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'payment_url': payment_url,
'payment_id': str(lava_invoice_id) if lava_invoice_id else None,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('Lava: ошибка создания платежа', error=e)
return None
@staticmethod
def _parse_lava_expired(value: Any) -> datetime | None:
"""Парсит поле ``expired`` из ответа Lava.
Принимаются только TZ-aware строки (ISO с offset/Z) или unix timestamp.
Naive-строки игнорируются TZ Lava в спеке не задокументирована,
а угадывание UTC может сместить срок жизни инвойса на несколько часов.
Если парсинг не удался caller использует fallback ``now + lifetime``.
"""
if value is None or value == '':
return None
if isinstance(value, (int, float)):
try:
return datetime.fromtimestamp(float(value), tz=UTC)
except (ValueError, OSError):
return None
if isinstance(value, str):
try:
from dateutil.parser import isoparse # type: ignore[import-not-found]
parsed = isoparse(value)
if parsed.tzinfo is None:
return None # без TZ доверять не можем
return parsed
except Exception:
return None
return None
@staticmethod
def _build_lava_hook_url() -> str | None:
"""Собирает абсолютный URL вебхука для Lava."""
webhook_path = settings.LAVA_WEBHOOK_PATH or '/lava-webhook'
base = (
getattr(settings, 'WEBHOOK_URL', None)
or getattr(settings, 'WEB_API_BASE_URL', None)
or getattr(settings, 'CABINET_URL', None)
)
if not base:
return None
suffix = webhook_path if webhook_path.startswith('/') else f'/{webhook_path}'
return f'{base.rstrip("/")}{suffix}'
async def process_lava_callback(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""Обрабатывает webhook от Lava (подпись уже проверена в webserver)."""
try:
lava_invoice_id = payload.get('invoice_id')
our_order_id = payload.get('order_id')
lava_status = (payload.get('status') or '').strip().lower()
pay_service = payload.get('pay_service')
if not our_order_id or not lava_status:
logger.warning('Lava webhook: отсутствуют обязательные поля')
return False
lava_crud = import_module('app.database.crud.lava')
payment = await lava_crud.get_lava_payment_by_order_id(db, our_order_id)
if not payment:
# Fallback по invoice_id, но строго проверяем совпадение order_id
if lava_invoice_id:
payment = await lava_crud.get_lava_payment_by_invoice_id(db, str(lava_invoice_id))
if payment and payment.order_id != our_order_id:
logger.error(
'Lava webhook: order_id mismatch',
webhook_order_id=our_order_id,
record_order_id=payment.order_id,
invoice_id=lava_invoice_id,
)
return False
if not payment:
logger.warning('Lava webhook: платеж не найден', order_id=our_order_id)
return False
locked = await lava_crud.get_lava_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Lava: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
if payment.is_paid:
logger.info('Lava webhook: платеж уже обработан', order_id=payment.order_id)
return True
# Терминальные неуспешные статусы — стики, защита от повторного успеха
if payment.status in {'amount_mismatch', 'cancelled', 'cancel', 'error', 'expired', 'failed'}:
# Если внезапно пришёл success после терминальной неудачи — это сигнал
# подделки или ошибки на стороне Lava, эскалируем.
if lava_status == 'success':
logger.error(
'Lava webhook: success на терминально-неуспешном платеже, игнорируется',
order_id=payment.order_id,
current_status=payment.status,
)
else:
logger.warning(
'Lava webhook: платёж в терминальном неуспешном статусе, игнорируется',
order_id=payment.order_id,
current_status=payment.status,
incoming_status=lava_status,
)
return True
if lava_status not in LAVA_STATUS_MAP:
logger.warning(
'Lava webhook: неизвестный статус, обрабатываем как pending',
order_id=payment.order_id,
incoming_status=lava_status,
)
internal_status, is_paid = LAVA_STATUS_MAP.get(lava_status, ('pending', False))
callback_payload = {
'lava_invoice_id': lava_invoice_id,
'status': lava_status,
'amount': payload.get('amount'),
'credited': payload.get('credited'),
'pay_service': pay_service,
'pay_time': payload.get('pay_time'),
'payer_details': payload.get('payer_details'),
'custom_fields': payload.get('custom_fields'),
}
# Сверяем сумму ДО зачисления
if is_paid:
# Lava webhook содержит amount (сумма счёта в рублях, float).
# Сверяем с тем, что мы отправляли на создание.
received_amount = payload.get('amount')
if received_amount is not None:
try:
received_kopeks = round(float(received_amount) * 100)
except (TypeError, ValueError):
received_kopeks = None
if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'Lava amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await lava_crud.update_lava_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
callback_payload=callback_payload,
)
return False
if is_paid:
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
if lava_invoice_id and not payment.lava_invoice_id:
payment.lava_invoice_id = str(lava_invoice_id)
# Сохраняем pay_service в metadata, не перезаписывая user-выбранный payment_method
if pay_service:
metadata_now = dict(getattr(payment, 'metadata_json', {}) or {})
metadata_now['actual_pay_service'] = str(pay_service).lower()
payment.metadata_json = metadata_now
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_lava_payment(db, payment, trigger='webhook')
payment = await lava_crud.update_lava_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('Lava webhook: ошибка обработки', error=e)
return False
async def _finalize_lava_payment(
self,
db: AsyncSession,
payment: Any,
*,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE-lock уже взят вызывающим.
"""
payment_module = import_module('app.services.payment_service')
lava_crud = import_module('app.database.crud.lava')
if payment.transaction_id:
logger.info(
'Lava платеж уже связан с транзакцией',
order_id=payment.order_id,
transaction_id=payment.transaction_id,
trigger=trigger,
)
return True
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=payment.order_id,
provider_name='lava',
)
if guest_result is not None:
return True
if not payment.is_paid:
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
balance_already_credited = bool(metadata.get('balance_credited'))
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error('Пользователь не найден для Lava', user_id=payment.user_id)
return False
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
for user_promo_group in getattr(user, 'user_promo_groups', []):
await db.refresh(user_promo_group, attribute_names=['promo_group'])
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
transaction_external_id = payment.order_id
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.LAVA,
)
display_name = settings.get_lava_display_name()
description = f'Пополнение через {display_name}'
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.LAVA,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await lava_crud.link_lava_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info('Lava платеж уже зачислил баланс ранее', order_id=payment.order_id)
return True
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.LAVA,
external_id=transaction_external_id,
)
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения Lava', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, 'bot', None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления Lava', error=error)
if getattr(self, 'bot', None) and user.telegram_id:
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
'✅ <b>Пополнение успешно!</b>\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
@@ -189,6 +189,57 @@ def _get_method_defaults() -> dict:
{'id': 'sbp', 'name': 'СБП'}, {'id': 'sbp', 'name': 'СБП'},
], ],
}, },
'etoplatezhi': {
'default_display_name': settings.get_etoplatezhi_display_name(),
'is_configured': settings.is_etoplatezhi_enabled(),
'default_min': settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS,
'default_max': settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'card', 'name': 'Карта'},
{'id': 'sbp', 'name': 'СБП'},
],
},
'antilopay': {
'default_display_name': settings.get_antilopay_display_name(),
'is_configured': settings.is_antilopay_enabled(),
'default_min': settings.ANTILOPAY_MIN_AMOUNT_KOPEKS,
'default_max': settings.ANTILOPAY_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'card', 'name': 'Карта'},
{'id': 'sbp', 'name': 'СБП'},
{'id': 'sberpay', 'name': 'SberPay'},
],
},
'jupiter': {
'default_display_name': settings.get_jupiter_display_name(),
'is_configured': settings.is_jupiter_enabled(),
'default_min': settings.JUPITER_MIN_AMOUNT_KOPEKS,
'default_max': settings.JUPITER_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'sbp', 'name': 'СБП'},
],
},
'donut': {
'default_display_name': settings.get_donut_display_name(),
'is_configured': settings.is_donut_enabled(),
'default_min': settings.DONUT_MIN_AMOUNT_KOPEKS,
'default_max': settings.DONUT_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'card', 'name': 'Карта'},
{'id': 'sbp', 'name': 'СБП'},
{'id': 'sbp_qr', 'name': 'СБП QR'},
],
},
'lava': {
'default_display_name': settings.get_lava_display_name(),
'is_configured': settings.is_lava_enabled(),
'default_min': settings.LAVA_MIN_AMOUNT_KOPEKS,
'default_max': settings.LAVA_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'card', 'name': 'Карта'},
{'id': 'sbp', 'name': 'СБП'},
],
},
} }
@@ -235,6 +286,11 @@ DEFAULT_METHOD_ORDER = [
'rollypay', 'rollypay',
'overpay', 'overpay',
'aurapay', 'aurapay',
'etoplatezhi',
'antilopay',
'jupiter',
'donut',
'lava',
] ]
+290
View File
@@ -15,17 +15,25 @@ from sqlalchemy.orm import selectinload
from sqlalchemy.types import String as SAString from sqlalchemy.types import String as SAString
from app.database.models import ( from app.database.models import (
AntilopayPayment,
AuraPayPayment,
CloudPaymentsPayment, CloudPaymentsPayment,
CryptoBotPayment, CryptoBotPayment,
DonutPayment,
EtoplatezhiPayment,
FreekassaPayment, FreekassaPayment,
HeleketPayment, HeleketPayment,
JupiterPayment,
KassaAiPayment, KassaAiPayment,
LavaPayment,
MulenPayPayment, MulenPayPayment,
OverpayPayment, OverpayPayment,
Pal24Payment, Pal24Payment,
PaymentMethod, PaymentMethod,
PayPearPayment,
PlategaPayment, PlategaPayment,
RioPayPayment, RioPayPayment,
RollyPayPayment,
SeverPayPayment, SeverPayPayment,
Transaction, Transaction,
TransactionType, TransactionType,
@@ -683,6 +691,280 @@ async def _search_overpay(db: AsyncSession, params: SearchParams) -> list[Pendin
return records 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]: async def _search_stars(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = ( stmt = (
select(Transaction) select(Transaction)
@@ -737,6 +1019,14 @@ _PROVIDER_SEARCH_MAP: dict[PaymentMethod, Any] = {
PaymentMethod.RIOPAY: _search_riopay, PaymentMethod.RIOPAY: _search_riopay,
PaymentMethod.SEVERPAY: _search_severpay, PaymentMethod.SEVERPAY: _search_severpay,
PaymentMethod.OVERPAY: _search_overpay, 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, PaymentMethod.TELEGRAM_STARS: _search_stars,
} }
+295
View File
@@ -30,10 +30,15 @@ from app.services.payment import (
WataPaymentMixin, WataPaymentMixin,
YooKassaPaymentMixin, YooKassaPaymentMixin,
) )
from app.services.payment.antilopay import AntilopayPaymentMixin
from app.services.payment.aurapay import AuraPayPaymentMixin from app.services.payment.aurapay import AuraPayPaymentMixin
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin 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.freekassa import FreekassaPaymentMixin
from app.services.payment.jupiter import JupiterPaymentMixin
from app.services.payment.kassa_ai import KassaAiPaymentMixin 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.overpay import OverpayPaymentMixin
from app.services.payment.paypear import PayPearPaymentMixin from app.services.payment.paypear import PayPearPaymentMixin
from app.services.payment.riopay import RioPayPaymentMixin from app.services.payment.riopay import RioPayPaymentMixin
@@ -482,6 +487,181 @@ async def link_aurapay_payment_to_transaction(*args, **kwargs):
return await aurapay_crud.link_aurapay_payment_to_transaction(*args, **kwargs) return await aurapay_crud.link_aurapay_payment_to_transaction(*args, **kwargs)
async def create_etoplatezhi_payment(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.create_etoplatezhi_payment(*args, **kwargs)
async def get_etoplatezhi_payment_by_order_id(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.get_etoplatezhi_payment_by_order_id(*args, **kwargs)
async def get_etoplatezhi_payment_by_invoice_id(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.get_etoplatezhi_payment_by_invoice_id(*args, **kwargs)
async def get_etoplatezhi_payment_by_id(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.get_etoplatezhi_payment_by_id(*args, **kwargs)
async def get_etoplatezhi_payment_by_id_for_update(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.get_etoplatezhi_payment_by_id_for_update(*args, **kwargs)
async def update_etoplatezhi_payment_status(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.update_etoplatezhi_payment_status(*args, **kwargs)
async def link_etoplatezhi_payment_to_transaction(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.link_etoplatezhi_payment_to_transaction(*args, **kwargs)
async def create_antilopay_payment(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.create_antilopay_payment(*args, **kwargs)
async def get_antilopay_payment_by_order_id(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.get_antilopay_payment_by_order_id(*args, **kwargs)
async def get_antilopay_payment_by_invoice_id(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.get_antilopay_payment_by_invoice_id(*args, **kwargs)
async def get_antilopay_payment_by_id(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.get_antilopay_payment_by_id(*args, **kwargs)
async def get_antilopay_payment_by_id_for_update(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.get_antilopay_payment_by_id_for_update(*args, **kwargs)
async def update_antilopay_payment_status(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.update_antilopay_payment_status(*args, **kwargs)
async def link_antilopay_payment_to_transaction(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.link_antilopay_payment_to_transaction(*args, **kwargs)
async def create_jupiter_payment(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.create_jupiter_payment(*args, **kwargs)
async def get_jupiter_payment_by_order_id(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.get_jupiter_payment_by_order_id(*args, **kwargs)
async def get_jupiter_payment_by_invoice_id(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.get_jupiter_payment_by_invoice_id(*args, **kwargs)
async def get_jupiter_payment_by_id(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.get_jupiter_payment_by_id(*args, **kwargs)
async def get_jupiter_payment_by_id_for_update(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.get_jupiter_payment_by_id_for_update(*args, **kwargs)
async def update_jupiter_payment_status(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.update_jupiter_payment_status(*args, **kwargs)
async def link_jupiter_payment_to_transaction(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.link_jupiter_payment_to_transaction(*args, **kwargs)
async def create_donut_payment(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.create_donut_payment(*args, **kwargs)
async def get_donut_payment_by_order_id(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.get_donut_payment_by_order_id(*args, **kwargs)
async def get_donut_payment_by_invoice_id(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.get_donut_payment_by_invoice_id(*args, **kwargs)
async def get_donut_payment_by_id(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.get_donut_payment_by_id(*args, **kwargs)
async def get_donut_payment_by_id_for_update(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.get_donut_payment_by_id_for_update(*args, **kwargs)
async def update_donut_payment_status(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.update_donut_payment_status(*args, **kwargs)
async def link_donut_payment_to_transaction(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.link_donut_payment_to_transaction(*args, **kwargs)
async def create_lava_payment(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.create_lava_payment(*args, **kwargs)
async def get_lava_payment_by_order_id(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.get_lava_payment_by_order_id(*args, **kwargs)
async def get_lava_payment_by_invoice_id(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.get_lava_payment_by_invoice_id(*args, **kwargs)
async def get_lava_payment_by_id(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.get_lava_payment_by_id(*args, **kwargs)
async def get_lava_payment_by_id_for_update(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.get_lava_payment_by_id_for_update(*args, **kwargs)
async def update_lava_payment_status(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.update_lava_payment_status(*args, **kwargs)
async def link_lava_payment_to_transaction(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.link_lava_payment_to_transaction(*args, **kwargs)
# Mapping from model_name to getter function name for providers # Mapping from model_name to getter function name for providers
# where it differs from the standard get_{model_name}_payment_by_id pattern. # where it differs from the standard get_{model_name}_payment_by_id pattern.
_GETTER_OVERRIDES: dict[str, str] = { _GETTER_OVERRIDES: dict[str, str] = {
@@ -509,6 +689,11 @@ class PaymentService(
RollyPayPaymentMixin, RollyPayPaymentMixin,
OverpayPaymentMixin, OverpayPaymentMixin,
AuraPayPaymentMixin, AuraPayPaymentMixin,
EtoplatezhiPaymentMixin,
AntilopayPaymentMixin,
JupiterPaymentMixin,
DonutPaymentMixin,
LavaPaymentMixin,
): ):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам.""" """Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
@@ -1016,6 +1201,116 @@ class PaymentService(
} }
return None return None
# --- Etoplatezhi ------------------------------------------------------
if payment_method == 'etoplatezhi':
if not settings.is_etoplatezhi_enabled():
logger.warning('Etoplatezhi is not enabled, cannot create guest payment')
return None
result = await self.create_etoplatezhi_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
return_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'etoplatezhi')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'etoplatezhi',
}
return None
# --- Antilopay --------------------------------------------------------
if payment_method == 'antilopay':
if not settings.is_antilopay_enabled():
logger.warning('Antilopay is not enabled, cannot create guest payment')
return None
result = await self.create_antilopay_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
return_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'antilopay')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'antilopay',
}
return None
# --- Jupiter ----------------------------------------------------------
if payment_method == 'jupiter':
if not settings.is_jupiter_enabled():
logger.warning('Jupiter is not enabled, cannot create guest payment')
return None
result = await self.create_jupiter_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
return_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'jupiter')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'jupiter',
}
return None
# --- Donut ------------------------------------------------------------
if payment_method == 'donut':
if not settings.is_donut_enabled():
logger.warning('Donut is not enabled, cannot create guest payment')
return None
result = await self.create_donut_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
return_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'donut')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'donut',
}
return None
# --- Lava -------------------------------------------------------------
if payment_method == 'lava':
if not settings.is_lava_enabled():
logger.warning('Lava is not enabled, cannot create guest payment')
return None
result = await self.create_lava_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
return_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'lava')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'lava',
}
return None
# --- Telegram Stars --------------------------------------------------- # --- Telegram Stars ---------------------------------------------------
if payment_method == 'telegram_stars': if payment_method == 'telegram_stars':
if not settings.TELEGRAM_STARS_ENABLED: if not settings.TELEGRAM_STARS_ENABLED:
@@ -18,16 +18,24 @@ from sqlalchemy.orm import selectinload
from app.config import settings from app.config import settings
from app.database.database import AsyncSessionLocal from app.database.database import AsyncSessionLocal
from app.database.models import ( from app.database.models import (
AntilopayPayment,
AuraPayPayment,
CloudPaymentsPayment, CloudPaymentsPayment,
CryptoBotPayment, CryptoBotPayment,
DonutPayment,
EtoplatezhiPayment,
FreekassaPayment, FreekassaPayment,
HeleketPayment, HeleketPayment,
JupiterPayment,
KassaAiPayment, KassaAiPayment,
LavaPayment,
MulenPayPayment, MulenPayPayment,
Pal24Payment, Pal24Payment,
PaymentMethod, PaymentMethod,
PayPearPayment,
PlategaPayment, PlategaPayment,
RioPayPayment, RioPayPayment,
RollyPayPayment,
SeverPayPayment, SeverPayPayment,
Transaction, Transaction,
TransactionType, TransactionType,
@@ -77,6 +85,11 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.RIOPAY, PaymentMethod.RIOPAY,
PaymentMethod.SEVERPAY, PaymentMethod.SEVERPAY,
PaymentMethod.OVERPAY, 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.RIOPAY,
PaymentMethod.SEVERPAY, PaymentMethod.SEVERPAY,
PaymentMethod.OVERPAY, 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() return settings.get_severpay_display_name()
if method == PaymentMethod.OVERPAY: if method == PaymentMethod.OVERPAY:
return settings.get_overpay_display_name() 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: if method == PaymentMethod.TELEGRAM_STARS:
return 'Telegram Stars' return 'Telegram Stars'
return method.value return method.value
@@ -161,6 +193,22 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
return settings.is_severpay_enabled() return settings.is_severpay_enabled()
if method == PaymentMethod.OVERPAY: if method == PaymentMethod.OVERPAY:
return settings.is_overpay_enabled() 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 return False
@@ -410,6 +458,62 @@ def _is_riopay_pending(payment: RioPayPayment) -> bool:
return status in {'pending'} 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: def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
payload = payment.payload or '' payload = payment.payload or ''
match = re.search(r'_(\d+)$', payload) match = re.search(r'_(\d+)$', payload)
@@ -779,6 +883,214 @@ async def _fetch_severpay_payments(db: AsyncSession, cutoff: datetime) -> list[P
return records 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]: async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = ( stmt = (
select(Transaction) select(Transaction)
@@ -828,6 +1140,14 @@ async def list_recent_pending_payments(
await _fetch_kassa_ai_payments(db, cutoff), await _fetch_kassa_ai_payments(db, cutoff),
await _fetch_riopay_payments(db, cutoff), await _fetch_riopay_payments(db, cutoff),
await _fetch_severpay_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), await _fetch_stars_transactions(db, cutoff),
) )
@@ -1026,6 +1346,126 @@ async def get_payment_record(
expires_at=getattr(payment, 'expires_at', None), 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: if method == PaymentMethod.TELEGRAM_STARS:
transaction = await db.get(Transaction, local_payment_id) transaction = await db.get(Transaction, local_payment_id)
if not transaction: if not transaction:
@@ -1098,6 +1538,27 @@ async def run_manual_check(
payment = result.get('payment') if result else None payment = result.get('payment') if result else None
else: else:
payment = None 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: else:
logger.warning('Manual check requested for unsupported method', method=method) logger.warning('Manual check requested for unsupported method', method=method)
return None return None
+67
View File
@@ -99,6 +99,73 @@ async def clear_pending_referral(telegram_id: int) -> None:
pass pass
# ---------------------------------------------------------------------------
# Pending campaign helpers (Redis)
#
# Mirrors pending_referral: lets us survive the case where /start <campaign>
# 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: async def _is_commission_limit_reached(db: AsyncSession, referrer_id: int, referral_id: int) -> bool:
"""Проверяет, исчерпан ли лимит комиссионных платежей для пары реферер-реферал.""" """Проверяет, исчерпан ли лимит комиссионных платежей для пары реферер-реферал."""
if settings.REFERRAL_MAX_COMMISSION_PAYMENTS <= 0: if settings.REFERRAL_MAX_COMMISSION_PAYMENTS <= 0:
+15
View File
@@ -95,6 +95,11 @@ class BotConfigurationService:
'ROLLYPAY': '💳 RollyPay', 'ROLLYPAY': '💳 RollyPay',
'OVERPAY': '💳 Overpay', 'OVERPAY': '💳 Overpay',
'AURAPAY': '💳 AuraPay', 'AURAPAY': '💳 AuraPay',
'ANTILOPAY': '🦌 Antilopay',
'ETOPLATEZHI': '💳 Etoplatezhi',
'JUPITER': '🪐 Jupiter',
'DONUT': '🍩 Donut',
'LAVA': '🌋 Lava',
'YOOKASSA': '🟣 YooKassa', 'YOOKASSA': '🟣 YooKassa',
'PLATEGA': '💳 {platega_name}', 'PLATEGA': '💳 {platega_name}',
'TRIBUTE': '🎁 Tribute', 'TRIBUTE': '🎁 Tribute',
@@ -159,6 +164,11 @@ class BotConfigurationService:
'ROLLYPAY': 'RollyPay: платёжный шлюз rollypay.io с СБП, картами и криптовалютой.', 'ROLLYPAY': 'RollyPay: платёжный шлюз rollypay.io с СБП, картами и криптовалютой.',
'OVERPAY': 'Overpay: платёжный шлюз pay.overpay.io с mTLS и поддержкой карт и СБП.', 'OVERPAY': 'Overpay: платёжный шлюз pay.overpay.io с mTLS и поддержкой карт и СБП.',
'AURAPAY': 'AuraPay: платёжный шлюз aurapay.tech с поддержкой карт и СБП.', 'AURAPAY': 'AuraPay: платёжный шлюз aurapay.tech с поддержкой карт и СБП.',
'ANTILOPAY': 'Antilopay: lk.antilopay.com, оплата картой, СБП и SberPay.',
'ETOPLATEZHI': 'Etoplatezhi: paymentpage.etoplatezhi.ru, оплата картой и через СБП.',
'JUPITER': 'Jupiter (FPGate P2P v2.1): app.juppiter.tech, эквайринг СБП с HMAC-SHA256.',
'DONUT': 'Donut P2P: gw.donut.business, P2P-оплата картой, СБП по телефону и QR.',
'LAVA': 'Lava Business: gate.lava.ru, оплата картой и СБП с HMAC-SHA256 и подтверждением через webhook.',
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.', 'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.', 'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
'PAL24': 'PAL24 / PayPalych подключения и лимиты.', 'PAL24': 'PAL24 / PayPalych подключения и лимиты.',
@@ -373,6 +383,11 @@ class BotConfigurationService:
'ROLLYPAY_': 'ROLLYPAY', 'ROLLYPAY_': 'ROLLYPAY',
'OVERPAY_': 'OVERPAY', 'OVERPAY_': 'OVERPAY',
'AURAPAY_': 'AURAPAY', 'AURAPAY_': 'AURAPAY',
'ANTILOPAY_': 'ANTILOPAY',
'ETOPLATEZHI_': 'ETOPLATEZHI',
'JUPITER_': 'JUPITER',
'DONUT_': 'DONUT',
'LAVA_': 'LAVA',
'PLATEGA_': 'PLATEGA', 'PLATEGA_': 'PLATEGA',
'MULENPAY_': 'MULENPAY', 'MULENPAY_': 'MULENPAY',
'PAL24_': 'PAL24', 'PAL24_': 'PAL24',
+153 -1
View File
@@ -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() aurapay_name = settings.get_aurapay_display_name()
methods.append( methods.append(
{ {
@@ -244,6 +272,99 @@ def get_available_payment_methods() -> list[dict[str, str]]:
} }
) )
if settings.is_etoplatezhi_sbp_enabled():
sbp_name = settings.get_etoplatezhi_sbp_display_name()
methods.append(
{
'id': 'etoplatezhi_sbp',
'name': sbp_name,
'icon': '📱',
'description': f'через {sbp_name}',
'callback': 'topup_etoplatezhi_sbp',
}
)
if settings.is_etoplatezhi_card_enabled():
card_name = settings.get_etoplatezhi_card_display_name()
methods.append(
{
'id': 'etoplatezhi_card',
'name': card_name,
'icon': '💳',
'description': f'через {card_name}',
'callback': 'topup_etoplatezhi_card',
}
)
if (
settings.is_etoplatezhi_enabled()
and not settings.is_etoplatezhi_sbp_enabled()
and not settings.is_etoplatezhi_card_enabled()
):
etoplatezhi_name = settings.get_etoplatezhi_display_name()
methods.append(
{
'id': 'etoplatezhi',
'name': etoplatezhi_name,
'icon': '💳',
'description': f'через {etoplatezhi_name}',
'callback': 'topup_etoplatezhi',
}
)
if settings.is_antilopay_sbp_enabled():
sbp_name = settings.get_antilopay_sbp_display_name()
methods.append(
{
'id': 'antilopay_sbp',
'name': sbp_name,
'icon': '📱',
'description': f'через {sbp_name}',
'callback': 'topup_antilopay_sbp',
}
)
if settings.is_antilopay_card_enabled():
card_name = settings.get_antilopay_card_display_name()
methods.append(
{
'id': 'antilopay_card',
'name': card_name,
'icon': '💳',
'description': f'через {card_name}',
'callback': 'topup_antilopay_card',
}
)
if settings.is_antilopay_sberpay_enabled():
sberpay_name = settings.get_antilopay_sberpay_display_name()
methods.append(
{
'id': 'antilopay_sberpay',
'name': sberpay_name,
'icon': '💳',
'description': f'через {sberpay_name}',
'callback': 'topup_antilopay_sberpay',
}
)
if (
settings.is_antilopay_enabled()
and not settings.is_antilopay_sbp_enabled()
and not settings.is_antilopay_card_enabled()
and not settings.is_antilopay_sberpay_enabled()
):
antilopay_name = settings.get_antilopay_display_name()
methods.append(
{
'id': 'antilopay',
'name': antilopay_name,
'icon': '💳',
'description': f'через {antilopay_name}',
'callback': 'topup_antilopay',
}
)
if settings.is_support_topup_enabled(): if settings.is_support_topup_enabled():
methods.append( methods.append(
{ {
@@ -381,6 +502,24 @@ def is_payment_method_available(method_id: str) -> bool:
return settings.is_overpay_enabled() return settings.is_overpay_enabled()
if method_id == 'aurapay': if method_id == 'aurapay':
return settings.is_aurapay_enabled() return settings.is_aurapay_enabled()
if method_id == 'aurapay_sbp':
return settings.is_aurapay_sbp_enabled()
if method_id == 'aurapay_card':
return settings.is_aurapay_card_enabled()
if method_id == 'etoplatezhi':
return settings.is_etoplatezhi_enabled()
if method_id == 'etoplatezhi_sbp':
return settings.is_etoplatezhi_sbp_enabled()
if method_id == 'etoplatezhi_card':
return settings.is_etoplatezhi_card_enabled()
if method_id == 'antilopay':
return settings.is_antilopay_enabled()
if method_id == 'antilopay_sbp':
return settings.is_antilopay_sbp_enabled()
if method_id == 'antilopay_card':
return settings.is_antilopay_card_enabled()
if method_id == 'antilopay_sberpay':
return settings.is_antilopay_sberpay_enabled()
if method_id == 'support': if method_id == 'support':
return settings.is_support_topup_enabled() return settings.is_support_topup_enabled()
return False return False
@@ -409,6 +548,15 @@ def get_payment_method_status() -> dict[str, bool]:
'rollypay': settings.is_rollypay_enabled(), 'rollypay': settings.is_rollypay_enabled(),
'overpay': settings.is_overpay_enabled(), 'overpay': settings.is_overpay_enabled(),
'aurapay': settings.is_aurapay_enabled(), 'aurapay': settings.is_aurapay_enabled(),
'aurapay_sbp': settings.is_aurapay_sbp_enabled(),
'aurapay_card': settings.is_aurapay_card_enabled(),
'etoplatezhi': settings.is_etoplatezhi_enabled(),
'etoplatezhi_sbp': settings.is_etoplatezhi_sbp_enabled(),
'etoplatezhi_card': settings.is_etoplatezhi_card_enabled(),
'antilopay': settings.is_antilopay_enabled(),
'antilopay_sbp': settings.is_antilopay_sbp_enabled(),
'antilopay_card': settings.is_antilopay_card_enabled(),
'antilopay_sberpay': settings.is_antilopay_sberpay_enabled(),
'support': settings.is_support_topup_enabled(), 'support': settings.is_support_topup_enabled(),
} }
@@ -454,4 +602,8 @@ def get_enabled_payment_methods_count() -> int:
count += 1 count += 1
if settings.is_aurapay_enabled(): if settings.is_aurapay_enabled():
count += 1 count += 1
if settings.is_etoplatezhi_enabled():
count += 1
if settings.is_antilopay_enabled():
count += 1
return count return count
+240
View File
@@ -1469,6 +1469,241 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
routes_registered = True routes_registered = True
# Etoplatezhi webhook
if settings.is_etoplatezhi_enabled():
@router.get(settings.ETOPLATEZHI_WEBHOOK_PATH)
async def etoplatezhi_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'etoplatezhi_webhook',
'enabled': settings.is_etoplatezhi_enabled(),
}
)
@router.post(settings.ETOPLATEZHI_WEBHOOK_PATH)
async def etoplatezhi_webhook(request: Request) -> JSONResponse:
try:
raw_body = await request.body()
payload = json.loads(raw_body)
except Exception as parse_error:
logger.error('Etoplatezhi webhook: failed to parse JSON', parse_error=parse_error)
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
# Подпись внутри JSON body (поле signature)
from app.services.etoplatezhi_service import etoplatezhi_service
if not etoplatezhi_service.verify_callback_signature(payload):
logger.warning('Etoplatezhi webhook: invalid signature')
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_etoplatezhi_callback',
)
if not success:
logger.error(
'Etoplatezhi webhook processing failed',
data=payload.get('payment', {}).get('id'),
)
except Exception as e:
logger.exception('Etoplatezhi webhook processing error', error=e)
# Always return 200 — Etoplatezhi expects 200 for valid signature
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
routes_registered = True
# Antilopay webhook
if settings.is_antilopay_enabled():
@router.get(settings.ANTILOPAY_WEBHOOK_PATH)
async def antilopay_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'antilopay_webhook',
'enabled': settings.is_antilopay_enabled(),
}
)
@router.post(settings.ANTILOPAY_WEBHOOK_PATH)
async def antilopay_webhook(request: Request) -> JSONResponse:
try:
raw_body = await request.body()
payload = json.loads(raw_body)
except Exception as parse_error:
logger.error('Antilopay webhook: failed to parse JSON', parse_error=parse_error)
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
# Подпись в заголовке X-Apay-Callback, верифицируется публичным ключом
from app.services.antilopay_service import antilopay_service
callback_signature = request.headers.get('X-Apay-Callback') or ''
if not antilopay_service.verify_callback_signature(raw_body, callback_signature):
logger.warning('Antilopay webhook: invalid signature')
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_antilopay_callback',
)
if not success:
logger.error(
'Antilopay webhook processing failed',
data=payload.get('payment_id'),
)
except Exception as e:
logger.exception('Antilopay webhook processing error', error=e)
# Always return 200 — Antilopay retries every 3min for 1hr on non-200
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
routes_registered = True
# Jupiter webhook (FPGate P2P v2.1)
if settings.is_jupiter_enabled():
@router.get(settings.JUPITER_WEBHOOK_PATH)
async def jupiter_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'jupiter_webhook',
'enabled': settings.is_jupiter_enabled(),
}
)
@router.post(settings.JUPITER_WEBHOOK_PATH)
async def jupiter_webhook(request: Request) -> JSONResponse:
try:
raw_body = await request.body()
payload = json.loads(raw_body)
except Exception as parse_error:
logger.error('Jupiter webhook: failed to parse JSON', parse_error=parse_error)
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
from app.services.jupiter_service import jupiter_service
if not jupiter_service.verify_callback_signature(payload):
logger.warning('Jupiter webhook: invalid signature')
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_jupiter_callback',
)
if not success:
logger.error(
'Jupiter webhook processing failed',
transaction_id=payload.get('transaction_id'),
)
except Exception as e:
logger.exception('Jupiter webhook processing error', error=e)
# FPGate ожидает HTTP 200 как подтверждение приёма callback
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
routes_registered = True
# Lava webhook (Lava Business)
if settings.is_lava_enabled():
@router.get(settings.LAVA_WEBHOOK_PATH)
async def lava_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'lava_webhook',
'enabled': settings.is_lava_enabled(),
}
)
@router.post(settings.LAVA_WEBHOOK_PATH)
async def lava_webhook(request: Request) -> JSONResponse:
try:
raw_body = await request.body()
payload = json.loads(raw_body)
except Exception as parse_error:
logger.error('Lava webhook: failed to parse JSON', parse_error=parse_error)
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
from app.services.lava_service import lava_service
received_signature = (request.headers.get('Authorization') or '').strip()
if not lava_service.verify_webhook_signature(raw_body, received_signature):
logger.warning('Lava webhook: invalid signature')
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_lava_callback',
)
if not success:
logger.error(
'Lava webhook processing failed',
order_id=payload.get('order_id'),
invoice_id=payload.get('invoice_id'),
)
except Exception as e:
logger.exception('Lava webhook processing error', error=e)
# Lava ожидает HTTP 200 как подтверждение приёма; иначе будет повтор до 5 раз раз в 150с
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
routes_registered = True
# Donut webhook (Donut P2P)
if settings.is_donut_enabled():
@router.get(settings.DONUT_WEBHOOK_PATH)
async def donut_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'donut_webhook',
'enabled': settings.is_donut_enabled(),
}
)
@router.post(settings.DONUT_WEBHOOK_PATH)
async def donut_webhook(request: Request) -> JSONResponse:
try:
raw_body = await request.body()
payload = json.loads(raw_body)
except Exception as parse_error:
logger.error('Donut webhook: failed to parse JSON', parse_error=parse_error)
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
from app.services.donut_service import donut_service
if not donut_service.verify_callback_signature(payload):
logger.warning('Donut webhook: invalid signature')
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_donut_callback',
)
if not success:
logger.error(
'Donut webhook processing failed',
transaction_id=payload.get('transaction_id'),
)
except Exception as e:
logger.exception('Donut webhook processing error', error=e)
# Donut ожидает HTTP 200 как подтверждение приёма callback
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
routes_registered = True
if routes_registered: if routes_registered:
@router.get('/health/payment-webhooks') @router.get('/health/payment-webhooks')
@@ -1493,6 +1728,11 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
'rollypay_enabled': settings.is_rollypay_enabled(), 'rollypay_enabled': settings.is_rollypay_enabled(),
'overpay_enabled': settings.is_overpay_enabled(), 'overpay_enabled': settings.is_overpay_enabled(),
'aurapay_enabled': settings.is_aurapay_enabled(), 'aurapay_enabled': settings.is_aurapay_enabled(),
'etoplatezhi_enabled': settings.is_etoplatezhi_enabled(),
'antilopay_enabled': settings.is_antilopay_enabled(),
'jupiter_enabled': settings.is_jupiter_enabled(),
'donut_enabled': settings.is_donut_enabled(),
'lava_enabled': settings.is_lava_enabled(),
} }
) )
@@ -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')
@@ -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')
@@ -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')
@@ -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')
@@ -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')
@@ -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')
@@ -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')
+1
View File
@@ -26,6 +26,7 @@ dependencies = [
'rich>=14.0', 'rich>=14.0',
'aiohttp-socks>=0.10.1', 'aiohttp-socks>=0.10.1',
'httpx[socks]>=0.27.0', 'httpx[socks]>=0.27.0',
'pycryptodome>=3.21.0',
] ]
[dependency-groups] [dependency-groups]
+549
View File
@@ -0,0 +1,549 @@
"""Tests for Apple In-App Purchase service and integration."""
from __future__ import annotations
import base64
import json
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from pydantic import ValidationError
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from app.config import settings
from app.external.apple_iap import AppleIAPService
@pytest.fixture
def anyio_backend() -> str:
return 'asyncio'
def _enable_apple_iap(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, 'APPLE_IAP_ENABLED', True, raising=False)
monkeypatch.setattr(settings, 'APPLE_IAP_KEY_ID', 'TEST_KEY_ID', raising=False)
monkeypatch.setattr(settings, 'APPLE_IAP_ISSUER_ID', 'test-issuer-id', raising=False)
monkeypatch.setattr(settings, 'APPLE_IAP_BUNDLE_ID', 'com.bitnet.vpnclient', raising=False)
monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Sandbox', raising=False)
# Use a dummy key -- we won't actually sign in tests
monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY', 'dummy-key', raising=False)
monkeypatch.setattr(
settings,
'APPLE_IAP_PRODUCTS',
json.dumps(
{
'com.bitnet.vpnclient.topup.100': 10_000,
'com.bitnet.vpnclient.topup.300': 30_000,
'com.bitnet.vpnclient.topup.500': 50_000,
'com.bitnet.vpnclient.topup.1000': 100_000,
'com.bitnet.vpnclient.topup.3000': 300_000,
}
),
raising=False,
)
# ---------------------------------------------------------------------------
# Product mapping
# ---------------------------------------------------------------------------
class TestProductMapping:
"""Test product ID to kopeks mapping."""
def test_all_products_mapped(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
products = settings.get_apple_iap_products()
assert len(products) == 5
def test_product_100(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
products = settings.get_apple_iap_products()
assert products['com.bitnet.vpnclient.topup.100'] == 10_000
def test_product_300(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
products = settings.get_apple_iap_products()
assert products['com.bitnet.vpnclient.topup.300'] == 30_000
def test_product_500(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
products = settings.get_apple_iap_products()
assert products['com.bitnet.vpnclient.topup.500'] == 50_000
def test_product_1000(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
products = settings.get_apple_iap_products()
assert products['com.bitnet.vpnclient.topup.1000'] == 100_000
def test_product_3000(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
products = settings.get_apple_iap_products()
assert products['com.bitnet.vpnclient.topup.3000'] == 300_000
def test_unknown_product_not_in_map(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
products = settings.get_apple_iap_products()
assert 'com.bitnet.vpnclient.topup.999' not in products
def test_invalid_json_returns_empty(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, 'APPLE_IAP_PRODUCTS', 'invalid-json', raising=False)
products = settings.get_apple_iap_products()
assert products == {}
# ---------------------------------------------------------------------------
# is_apple_iap_enabled()
# ---------------------------------------------------------------------------
class TestAppleIAPEnabled:
"""Test is_apple_iap_enabled() helper."""
def test_enabled_with_all_params(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
assert settings.is_apple_iap_enabled() is True
def test_disabled_when_flag_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
monkeypatch.setattr(settings, 'APPLE_IAP_ENABLED', False, raising=False)
assert settings.is_apple_iap_enabled() is False
def test_disabled_when_key_id_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
monkeypatch.setattr(settings, 'APPLE_IAP_KEY_ID', None, raising=False)
assert settings.is_apple_iap_enabled() is False
def test_disabled_when_issuer_id_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
monkeypatch.setattr(settings, 'APPLE_IAP_ISSUER_ID', None, raising=False)
assert settings.is_apple_iap_enabled() is False
def test_disabled_when_no_private_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY', None, raising=False)
monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY_PATH', None, raising=False)
assert settings.is_apple_iap_enabled() is False
def test_enabled_with_key_path_only(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY', None, raising=False)
monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY_PATH', '/tmp/test.p8', raising=False) # noqa: S108
assert settings.is_apple_iap_enabled() is True
# ---------------------------------------------------------------------------
# validate_transaction_info
# ---------------------------------------------------------------------------
class TestTransactionValidation:
"""Test validate_transaction_info."""
def test_valid_transaction(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
txn_info = {
'bundleId': 'com.bitnet.vpnclient',
'productId': 'com.bitnet.vpnclient.topup.100',
'type': 'Consumable',
}
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
assert result is None # None means valid
def test_wrong_bundle_id(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
txn_info = {
'bundleId': 'com.other.app',
'productId': 'com.bitnet.vpnclient.topup.100',
'type': 'Consumable',
}
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
assert result is not None
assert 'Bundle ID' in result
def test_wrong_product_id(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
txn_info = {
'bundleId': 'com.bitnet.vpnclient',
'productId': 'com.bitnet.vpnclient.topup.500',
'type': 'Consumable',
}
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
assert result is not None
assert 'Product ID' in result
def test_wrong_type(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
txn_info = {
'bundleId': 'com.bitnet.vpnclient',
'productId': 'com.bitnet.vpnclient.topup.100',
'type': 'Auto-Renewable Subscription',
}
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
assert result is not None
assert 'type' in result
def test_revoked_transaction(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
txn_info = {
'bundleId': 'com.bitnet.vpnclient',
'productId': 'com.bitnet.vpnclient.topup.100',
'type': 'Consumable',
'revocationDate': 1700000000000,
}
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
assert result is not None
assert 'revoked' in result.lower()
def test_valid_without_revocation(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A transaction without revocationDate should be valid."""
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
txn_info = {
'bundleId': 'com.bitnet.vpnclient',
'productId': 'com.bitnet.vpnclient.topup.100',
'type': 'Consumable',
}
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
assert result is None
# ---------------------------------------------------------------------------
# Environment URL selection
# ---------------------------------------------------------------------------
class TestBaseUrl:
"""Test environment URL selection."""
def test_production_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
url = service._get_base_url('Production')
assert 'api.storekit.itunes.apple.com' in url
def test_sandbox_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
url = service._get_base_url('Sandbox')
assert 'api.storekit-sandbox.itunes.apple.com' in url
def test_default_uses_config(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
url = service._get_base_url()
assert 'sandbox' in url # Fixture sets Sandbox
# ---------------------------------------------------------------------------
# _decode_jws_payload (raw decode, no verification)
# ---------------------------------------------------------------------------
class TestJWSPayloadDecoding:
"""Test _decode_jws_payload."""
def test_decode_valid_jws(self) -> None:
service = AppleIAPService()
header = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b'=').decode()
payload_data = {'bundleId': 'com.test', 'productId': 'test.product'}
payload = base64.urlsafe_b64encode(json.dumps(payload_data).encode()).rstrip(b'=').decode()
signature = base64.urlsafe_b64encode(b'fake-signature').rstrip(b'=').decode()
jws = f'{header}.{payload}.{signature}'
result = service._decode_jws_payload(jws)
assert result is not None
assert result['bundleId'] == 'com.test'
assert result['productId'] == 'test.product'
def test_decode_invalid_jws(self) -> None:
service = AppleIAPService()
result = service._decode_jws_payload('not-a-jws')
assert result is None
def test_decode_empty_string(self) -> None:
service = AppleIAPService()
result = service._decode_jws_payload('')
assert result is None
# ---------------------------------------------------------------------------
# _verify_and_decode_jws (x5c + ES256 verification)
# ---------------------------------------------------------------------------
class TestVerifyAndDecodeJWS:
"""Test _verify_and_decode_jws -- the full x5c chain + signature path."""
def test_rejects_bad_format(self) -> None:
service = AppleIAPService()
assert service._verify_and_decode_jws('only-two.parts') is None
def test_rejects_missing_x5c(self) -> None:
service = AppleIAPService()
# Valid 3-part JWS but header has no x5c
header = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b'=').decode()
payload = base64.urlsafe_b64encode(b'{}').rstrip(b'=').decode()
sig = base64.urlsafe_b64encode(b'sig').rstrip(b'=').decode()
assert service._verify_and_decode_jws(f'{header}.{payload}.{sig}') is None
def test_rejects_empty_x5c(self) -> None:
service = AppleIAPService()
header = base64.urlsafe_b64encode(json.dumps({'alg': 'ES256', 'x5c': []}).encode()).rstrip(b'=').decode()
payload = base64.urlsafe_b64encode(b'{}').rstrip(b'=').decode()
sig = base64.urlsafe_b64encode(b'sig').rstrip(b'=').decode()
assert service._verify_and_decode_jws(f'{header}.{payload}.{sig}') is None
def test_verify_notification_delegates(self) -> None:
"""verify_notification should delegate to _verify_and_decode_jws."""
service = AppleIAPService()
service._verify_and_decode_jws = MagicMock(return_value={'test': True})
result = service.verify_notification('signed.payload.jws')
service._verify_and_decode_jws.assert_called_once_with('signed.payload.jws')
assert result == {'test': True}
# ---------------------------------------------------------------------------
# verify_transaction with mocked HTTP
# ---------------------------------------------------------------------------
@pytest.mark.anyio('asyncio')
class TestVerifyTransaction:
"""Test verify_transaction with mocked _fetch_transaction."""
@staticmethod
def _ok_response(signed_info: str = 'header.payload.sig') -> MagicMock:
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {'signedTransactionInfo': signed_info}
return resp
@staticmethod
def _error_response(status: int, text: str = '') -> MagicMock:
resp = MagicMock()
resp.status_code = status
resp.text = text
return resp
async def test_successful_verification(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
txn_data = {
'bundleId': 'com.bitnet.vpnclient',
'productId': 'com.bitnet.vpnclient.topup.100',
'type': 'Consumable',
'transactionId': '2000000123456789',
'environment': 'Sandbox',
}
monkeypatch.setattr(service, '_fetch_transaction', AsyncMock(return_value=self._ok_response()))
monkeypatch.setattr(service, '_verify_and_decode_jws', lambda token: txn_data)
result = await service.verify_transaction('2000000123456789', 'Sandbox')
assert result is not None
assert result['bundleId'] == 'com.bitnet.vpnclient'
assert result['transactionId'] == '2000000123456789'
async def test_verification_with_jws_failure(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""If _verify_and_decode_jws returns None, verify_transaction returns None."""
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
monkeypatch.setattr(service, '_fetch_transaction', AsyncMock(return_value=self._ok_response()))
monkeypatch.setattr(service, '_verify_and_decode_jws', lambda token: None)
result = await service.verify_transaction('2000000123456789', 'Sandbox')
assert result is None
async def test_transaction_not_found_both_envs(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""404 on primary triggers fallback; 404 on fallback returns None."""
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
fetch_mock = AsyncMock(return_value=self._error_response(404))
monkeypatch.setattr(service, '_fetch_transaction', fetch_mock)
result = await service.verify_transaction('nonexistent', 'Sandbox')
assert result is None
# Should have been called twice (primary + fallback)
assert fetch_mock.call_count == 2
async def test_fallback_succeeds_on_404(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""404 on primary, 200 on fallback -- should succeed."""
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
txn_data = {'bundleId': 'com.bitnet.vpnclient', 'type': 'Consumable'}
responses = [self._error_response(404), self._ok_response()]
fetch_mock = AsyncMock(side_effect=responses)
monkeypatch.setattr(service, '_fetch_transaction', fetch_mock)
monkeypatch.setattr(service, '_verify_and_decode_jws', lambda token: txn_data)
result = await service.verify_transaction('12345', 'Production')
assert result is not None
assert fetch_mock.call_count == 2
async def test_network_error_no_retry(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Network error (None response) should not retry."""
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
fetch_mock = AsyncMock(return_value=None)
monkeypatch.setattr(service, '_fetch_transaction', fetch_mock)
result = await service.verify_transaction('12345', 'Sandbox')
assert result is None
assert fetch_mock.call_count == 1 # no fallback on network error
async def test_5xx_no_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""5xx errors should not trigger fallback (only 4xx does)."""
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
fetch_mock = AsyncMock(return_value=self._error_response(500, 'Internal'))
monkeypatch.setattr(service, '_fetch_transaction', fetch_mock)
result = await service.verify_transaction('12345', 'Sandbox')
assert result is None
assert fetch_mock.call_count == 1
async def test_rate_limit_triggers_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""429 is 4xx -> triggers fallback."""
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
fetch_mock = AsyncMock(return_value=self._error_response(429))
monkeypatch.setattr(service, '_fetch_transaction', fetch_mock)
result = await service.verify_transaction('123', 'Sandbox')
assert result is None
assert fetch_mock.call_count == 2 # primary + fallback
async def test_no_signed_transaction_info(self, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {} # missing signedTransactionInfo
monkeypatch.setattr(service, '_fetch_transaction', AsyncMock(return_value=resp))
result = await service.verify_transaction('123', 'Sandbox')
assert result is None
# ---------------------------------------------------------------------------
# Schema validation
# ---------------------------------------------------------------------------
class TestApplePurchaseRequestSchema:
"""Test ApplePurchaseRequest pydantic validation."""
def test_valid_request(self) -> None:
from app.cabinet.schemas.apple_iap import ApplePurchaseRequest
req = ApplePurchaseRequest(
product_id='com.bitnet.vpnclient.topup.100',
transaction_id='2000000123456789',
)
assert req.transaction_id == '2000000123456789'
def test_rejects_non_numeric_transaction_id(self) -> None:
from app.cabinet.schemas.apple_iap import ApplePurchaseRequest
with pytest.raises(Exception, match='digits'):
ApplePurchaseRequest(
product_id='com.bitnet.vpnclient.topup.100',
transaction_id='abc-not-numeric',
)
def test_rejects_empty_transaction_id(self) -> None:
from app.cabinet.schemas.apple_iap import ApplePurchaseRequest
with pytest.raises(ValidationError):
ApplePurchaseRequest(
product_id='com.bitnet.vpnclient.topup.100',
transaction_id='',
)
def test_rejects_too_long_transaction_id(self) -> None:
from app.cabinet.schemas.apple_iap import ApplePurchaseRequest
with pytest.raises(ValidationError):
ApplePurchaseRequest(
product_id='com.bitnet.vpnclient.topup.100',
transaction_id='1' * 65,
)
def test_no_environment_field(self) -> None:
"""Schema should not accept environment -- it's server-side only."""
from app.cabinet.schemas.apple_iap import ApplePurchaseRequest
req = ApplePurchaseRequest(
product_id='com.bitnet.vpnclient.topup.100',
transaction_id='123',
)
assert not hasattr(req, 'environment')
# ---------------------------------------------------------------------------
# Sandbox detection
# ---------------------------------------------------------------------------
class TestSandboxDetection:
"""Test that sandbox transactions don't credit real balance."""
def test_sandbox_env_detected_from_txn_info(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""validate_transaction_info does not reject sandbox env -- that's handled at the route level."""
_enable_apple_iap(monkeypatch)
service = AppleIAPService()
txn_info = {
'bundleId': 'com.bitnet.vpnclient',
'productId': 'com.bitnet.vpnclient.topup.100',
'type': 'Consumable',
'environment': 'Sandbox',
}
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
assert result is None # validation passes -- sandbox check is higher up
def test_production_txn_on_production_passes(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Production environment in txn_info + Production config = proceed normally."""
_enable_apple_iap(monkeypatch)
monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Production', raising=False)
txn_info = {'environment': 'Production'}
is_sandbox = txn_info.get('environment') == 'Sandbox'
assert is_sandbox is False
def test_sandbox_txn_on_production_detected(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Sandbox environment in txn_info + Production config = sandbox detected."""
_enable_apple_iap(monkeypatch)
monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Production', raising=False)
txn_info = {'environment': 'Sandbox'}
is_sandbox = txn_info.get('environment') == 'Sandbox'
should_skip_balance = is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production'
assert should_skip_balance is True
def test_sandbox_txn_on_sandbox_credits_normally(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Sandbox environment in txn_info + Sandbox config = credit normally (testing)."""
_enable_apple_iap(monkeypatch)
monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Sandbox', raising=False)
txn_info = {'environment': 'Sandbox'}
is_sandbox = txn_info.get('environment') == 'Sandbox'
should_skip_balance = is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production'
assert should_skip_balance is False
Generated
+32
View File
@@ -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" }, { 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]] [[package]]
name = "pycryptodomex" name = "pycryptodomex"
version = "3.23.0" version = "3.23.0"
@@ -1155,6 +1185,7 @@ dependencies = [
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "httpx", extra = ["socks"] }, { name = "httpx", extra = ["socks"] },
{ name = "packaging" }, { name = "packaging" },
{ name = "pycryptodome" },
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "pyyaml" }, { name = "pyyaml" },
@@ -1187,6 +1218,7 @@ requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.129.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.129.0" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.27.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.27.0" },
{ name = "packaging", specifier = ">=26.0" }, { name = "packaging", specifier = ">=26.0" },
{ name = "pycryptodome", specifier = ">=3.21.0" },
{ name = "pyjwt", specifier = ">=2.11.0" }, { name = "pyjwt", specifier = ">=2.11.0" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0" }, { name = "python-dateutil", specifier = ">=2.9.0.post0" },
{ name = "pyyaml", specifier = ">=6.0.3" }, { name = "pyyaml", specifier = ">=6.0.3" },