Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29d3e8984b | |||
| b5a066628f | |||
| d6442b87df | |||
| 31e3ccd24c | |||
| 3d4b7b4582 | |||
| cedf4922fd | |||
| 491f09d547 | |||
| 0d0646770d | |||
| 17732a0370 | |||
| cd8be32671 | |||
| afea054c8f | |||
| f321ded9c0 | |||
| 3fce64858c | |||
| 1ab1ff90bf | |||
| 719664208e | |||
| 6524f66da2 | |||
| 17ac3da3c4 | |||
| e85c40f8cd | |||
| ecde2fb8f0 | |||
| 99648a956e | |||
| 2478ff7c3d | |||
| 2385814d77 |
+4
-1
@@ -72,8 +72,11 @@ SMTP_PASSWORD=
|
||||
# Email отправителя (если не указан, используется SMTP_USER)
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_FROM_NAME=VPN Service
|
||||
# Использовать TLS шифрование
|
||||
# Использовать STARTTLS (порт 587 / 25). Не путать с SMTP_USE_SSL.
|
||||
SMTP_USE_TLS=true
|
||||
# Использовать implicit TLS (SMTPS). Автоматически включается при SMTP_PORT=465.
|
||||
# Для портов 25/587 оставить false.
|
||||
SMTP_USE_SSL=false
|
||||
|
||||
# Уведомления администраторов
|
||||
ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.53.0"
|
||||
".": "3.54.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## [3.54.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.53.0...v3.54.0) (2026-05-04)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add subscription reissue with 15-min cooldown ([1ab1ff9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ab1ff90bf6fc243da45a53b2d549464e47c2f31))
|
||||
* Apple IAP integration with security hardening ([ecde2fb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ecde2fb8f05620889f45fea4410e9069b99340a5))
|
||||
* integrate Antilopay payment provider (API v2) ([7196642](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/719664208e94fe1e528ff0f85f500e5b96555dc9))
|
||||
* integrate Etoplatezhi payment provider ([6524f66](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6524f66da2717001aba99831f8dec93ad8f6e6f8))
|
||||
* integrate Jupiter (FPGate P2P) and Donut payment providers ([f321ded](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f321ded9c0033ff3848a92aef44cbfef7f8d3883))
|
||||
* integrate Lava Business payment provider ([afea054](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/afea054c8f8898d076d667c2d17248052578cbce))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add pycryptodome dependency for Antilopay RSA signing ([3fce648](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3fce64858cc4e536ab32acc4c68213c208b6081c))
|
||||
* apple refund handler — lock apple_transactions row to prevent double deduction ([e85c40f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e85c40f8cdca1e3392da06f6539f0043f923b731))
|
||||
* AuraPay webhook signature + add SBP/Card payment method selection ([17ac3da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17ac3da3c4da0f36015e338451a22a1a7a433524))
|
||||
* expired_1d notification — use PricingEngine instead of hardcoded PRICE_30_DAYS ([2478ff7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2478ff7c3d0c426c5f64dec71d17df13de60702a))
|
||||
* guide mode buttons — support external type alias, extract urlScheme from blocks ([2385814](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2385814d77c6b847fb0df248b5aaaa2ff27fd20e))
|
||||
* persist campaign across bot→webapp registration handoff via Redis ([99648a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/99648a956e20cb99d406455db2f26db469ba7235))
|
||||
* register all providers in payment search and verification services ([cd8be32](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cd8be326716e2c3253d9b2935da1a7927725c9af))
|
||||
* **tests:** clean up pre-existing ruff lint warnings in apple_iap tests ([0d06467](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0d0646770d2a1cdccec14ea3b78dc12412f95d0a))
|
||||
|
||||
## [3.53.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.52.1...v3.53.0) (2026-04-29)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.53.0" # x-release-please-version
|
||||
ARG VERSION="v3.54.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ Bedolaga — полнофункциональная платформа для п
|
||||
|
||||
### 💳 Платежи
|
||||
|
||||
- 🏦 **18 платёжных провайдеров** одновременно
|
||||
- 🏦 **24+ платёжных провайдера** одновременно
|
||||
- 💰 Единый баланс: пополнение любым способом → покупка с баланса
|
||||
- ⚡ Автопокупка подписки после пополнения
|
||||
- 💾 Рекуррентные платежи (сохранённые карты)
|
||||
@@ -127,6 +127,12 @@ Bedolaga — полнофункциональная платформа для п
|
||||
| 🤝 | **[RollyPay](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)** 🔸 | СБП, карты, крипто | RUB → USDT |
|
||||
| 🤝 | **[AuraPay](https://aurapay.tech/)** 🔸 | Карты, СБП | RUB |
|
||||
| 🤝 | **[Overpay](https://overpay.pro/)** 🔸 | Карты, СБП | RUB |
|
||||
| 🦌 | **Antilopay** | Карты, СБП, SberPay (RSA подпись) | RUB |
|
||||
| 💳 | **Etoplatezhi** | Карты, СБП | RUB |
|
||||
| 🪐 | **[Jupiter](https://t.me/k_juppiter)** 🔸 | СБП через QR (FPGate P2P v2.1) | RUB |
|
||||
| 🍩 | **[Donut](https://t.me/donut_payment)** 🔸 | Карты, СБП по телефону, СБП QR (P2P) | RUB |
|
||||
| 🌋 | **Lava Business** | Карты, СБП (gate.lava.ru) | RUB |
|
||||
| 🍎 | **Apple In-App Purchase** | Покупки через iOS App Store | USD |
|
||||
| 📲 | **Tribute** | Telegram-платежи | RUB |
|
||||
|
||||
</div>
|
||||
@@ -210,6 +216,32 @@ Bedolaga — официальный партнёр платёжного шлюз
|
||||
|
||||
📩 Менеджер: [@A_OverPay](https://t.me/A_OverPay) | 🌐 [overpay.pro](https://overpay.pro/)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр Jupiter (FPGate P2P)**
|
||||
|
||||
Bedolaga — официальный партнёр платёжного шлюза **Jupiter** (FPGate P2P v2.1).<br>
|
||||
Эквайринг СБП через QR-код банковского приложения, HMAC-SHA256 подпись.<br>
|
||||
Высокая проходимость, callback-driven архитектура, защита от replay-атак.<br>
|
||||
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
|
||||
|
||||
📩 Менеджер: [@k_juppiter](https://t.me/k_juppiter)
|
||||
|
||||
</td>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр Donut**
|
||||
|
||||
Bedolaga — официальный партнёр платёжной системы **Donut** (Donut P2P).<br>
|
||||
P2P-оплата картой, СБП по номеру телефона и СБП QR — три метода через единый API.<br>
|
||||
HMAC-SHA256 подпись, sticky terminal-status guard, защита от amount tampering.<br>
|
||||
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
|
||||
|
||||
📩 Менеджер: [@donut_payment](https://t.me/donut_payment)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -275,7 +307,7 @@ docker compose up -d
|
||||
| | Раздел | Описание |
|
||||
|:---:|:---|:---|
|
||||
| 🚀 | [Быстрый старт](https://docs.bedolagam.ru/getting-started/quickstart) | Развёртывание за 5 минут |
|
||||
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 18 провайдеров, webhook, фискализация |
|
||||
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 24+ провайдера, webhook, фискализация, Apple IAP |
|
||||
| 📦 | [Подписки и тарифы](https://docs.bedolagam.ru/bot/subscriptions) | Конфигурация планов и трафика |
|
||||
| 👥 | [Реферальная программа](https://docs.bedolagam.ru/bot/referral-program) | Партнёрка и вывод средств |
|
||||
| 🖥 | [Cabinet](https://docs.bedolagam.ru/cabinet/overview) | Настройка веб-кабинета |
|
||||
|
||||
@@ -69,6 +69,13 @@ from .wheel import router as wheel_router
|
||||
from .withdrawal import router as withdrawal_router
|
||||
|
||||
|
||||
# Conditional imports
|
||||
try:
|
||||
from .apple_iap import router as apple_iap_router
|
||||
except ImportError:
|
||||
apple_iap_router = None
|
||||
|
||||
|
||||
# Main cabinet router
|
||||
router = APIRouter(prefix='/cabinet', tags=['Cabinet'], redirect_slashes=False)
|
||||
|
||||
@@ -81,6 +88,11 @@ router.include_router(subscription_router)
|
||||
router.include_router(multi_tariff_subscription_router)
|
||||
router.include_router(balance_router)
|
||||
router.include_router(referral_router)
|
||||
|
||||
# Apple IAP routes
|
||||
if apple_iap_router is not None:
|
||||
router.include_router(apple_iap_router)
|
||||
|
||||
router.include_router(partner_application_router)
|
||||
router.include_router(withdrawal_router)
|
||||
# Notifications router MUST be before tickets router to avoid route conflict
|
||||
|
||||
@@ -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
@@ -172,74 +172,112 @@ async def _process_campaign_bonus(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
campaign_slug: str | None,
|
||||
telegram_id: int | None = None,
|
||||
) -> CampaignBonusInfo | None:
|
||||
"""Process campaign bonus for user during auth. Never raises."""
|
||||
"""Process campaign bonus for user during auth. Never raises.
|
||||
|
||||
If ``campaign_slug`` is not provided but ``telegram_id`` is given, the
|
||||
function falls back to Redis ``pending_campaign:{telegram_id}`` -- populated
|
||||
by the bot's /start handler when a user opens an advertising campaign link
|
||||
but then completes registration via the cabinet WebApp (Telegram menu
|
||||
button) instead of the bot dialog. The Redis entry is cleared after a
|
||||
successful consumption attempt.
|
||||
"""
|
||||
pending_campaign_consumed = False
|
||||
if not campaign_slug and telegram_id:
|
||||
try:
|
||||
from app.services.referral_service import get_pending_campaign
|
||||
|
||||
pending = await get_pending_campaign(telegram_id)
|
||||
if pending and pending.get('campaign_slug'):
|
||||
campaign_slug = pending['campaign_slug']
|
||||
pending_campaign_consumed = True
|
||||
logger.info(
|
||||
'Resolved campaign from Redis pending_campaign (cabinet)',
|
||||
telegram_id=telegram_id,
|
||||
campaign_slug=campaign_slug,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to check pending campaign', error=e)
|
||||
|
||||
if not campaign_slug:
|
||||
return None
|
||||
try:
|
||||
campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True)
|
||||
if not campaign:
|
||||
return None
|
||||
try:
|
||||
campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True)
|
||||
if not campaign:
|
||||
return None
|
||||
|
||||
# Skip if user IS the campaign partner — prevent self-referral
|
||||
if campaign.partner_user_id and campaign.partner_user_id == user.id:
|
||||
logger.debug(
|
||||
'Skipping campaign attribution: user is the campaign partner',
|
||||
user_id=user.id,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Lock user row to prevent concurrent bonus application (race condition)
|
||||
await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
|
||||
existing = await get_campaign_registration_by_user(db, user.id)
|
||||
if existing:
|
||||
logger.debug('User already has campaign registration', user_id=user.id)
|
||||
return None
|
||||
|
||||
# Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан)
|
||||
if campaign.partner_user_id and not user.referred_by_id:
|
||||
user.referred_by_id = campaign.partner_user_id
|
||||
await db.flush()
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
|
||||
logger.info(
|
||||
'Referral set from campaign partner',
|
||||
# Skip if user IS the campaign partner — prevent self-referral
|
||||
if campaign.partner_user_id and campaign.partner_user_id == user.id:
|
||||
logger.debug(
|
||||
'Skipping campaign attribution: user is the campaign partner',
|
||||
user_id=user.id,
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Failed to process referral from campaign partner', error=e)
|
||||
return None
|
||||
|
||||
service = AdvertisingCampaignService()
|
||||
result = await service.apply_campaign_bonus(db, user, campaign)
|
||||
if not result.success:
|
||||
return None
|
||||
# Lock user row to prevent concurrent bonus application (race condition)
|
||||
await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
|
||||
# Refresh user to get updated balance after bonus
|
||||
await db.refresh(user)
|
||||
existing = await get_campaign_registration_by_user(db, user.id)
|
||||
if existing:
|
||||
logger.debug('User already has campaign registration', user_id=user.id)
|
||||
return None
|
||||
|
||||
return CampaignBonusInfo(
|
||||
campaign_name=campaign.name,
|
||||
bonus_type=result.bonus_type or campaign.bonus_type,
|
||||
balance_kopeks=result.balance_kopeks,
|
||||
subscription_days=result.subscription_days,
|
||||
tariff_name=result.tariff_name,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug)
|
||||
try:
|
||||
await db.rollback()
|
||||
# Re-fetch user so session stays usable for the caller
|
||||
# Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан)
|
||||
if campaign.partner_user_id and not user.referred_by_id:
|
||||
user.referred_by_id = campaign.partner_user_id
|
||||
await db.flush()
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
|
||||
logger.info(
|
||||
'Referral set from campaign partner',
|
||||
user_id=user.id,
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Failed to process referral from campaign partner', error=e)
|
||||
|
||||
service = AdvertisingCampaignService()
|
||||
result = await service.apply_campaign_bonus(db, user, campaign)
|
||||
if not result.success:
|
||||
return None
|
||||
|
||||
# Refresh user to get updated balance after bonus
|
||||
await db.refresh(user)
|
||||
|
||||
return CampaignBonusInfo(
|
||||
campaign_name=campaign.name,
|
||||
bonus_type=result.bonus_type or campaign.bonus_type,
|
||||
balance_kopeks=result.balance_kopeks,
|
||||
subscription_days=result.subscription_days,
|
||||
tariff_name=result.tariff_name,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to rollback after campaign bonus error', user_id=user.id)
|
||||
return None
|
||||
logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug)
|
||||
try:
|
||||
await db.rollback()
|
||||
# Re-fetch user so session stays usable for the caller
|
||||
await db.refresh(user)
|
||||
except Exception:
|
||||
logger.exception('Failed to rollback after campaign bonus error', user_id=user.id)
|
||||
return None
|
||||
finally:
|
||||
# Clear Redis pending_campaign whenever we consumed it. Done regardless
|
||||
# of success — if processing failed (already applied, race, exception),
|
||||
# we don't want to keep retrying on every subsequent login.
|
||||
if pending_campaign_consumed and telegram_id:
|
||||
try:
|
||||
from app.services.referral_service import clear_pending_campaign
|
||||
|
||||
await clear_pending_campaign(telegram_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _process_referral_code(
|
||||
@@ -584,8 +622,11 @@ async def auth_telegram(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
# Process campaign bonus.
|
||||
# Pass telegram_id so the function can fall back to Redis pending_campaign
|
||||
# if the user came via /start <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:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
@@ -691,8 +732,8 @@ async def auth_telegram_widget(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
# Process campaign bonus (pending_campaign Redis fallback for Telegram Login Widget)
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=request.id)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
@@ -837,7 +878,8 @@ async def auth_telegram_oidc(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
# Process campaign bonus (pending_campaign Redis fallback for Telegram OIDC)
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=telegram_id)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
|
||||
@@ -914,6 +914,99 @@ async def create_topup(
|
||||
detail='Failed to create AuraPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'jupiter':
|
||||
if not settings.is_jupiter_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Jupiter payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_jupiter_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Jupiter payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'donut':
|
||||
if not settings.is_donut_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Donut payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_donut_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Donut payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'lava':
|
||||
if not settings.is_lava_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Lava payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_lava_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Lava payment',
|
||||
)
|
||||
|
||||
else:
|
||||
# For other payment methods, redirect to bot
|
||||
raise HTTPException(
|
||||
@@ -1065,6 +1158,45 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.JUPITER:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'cancelled': ('❌', 'Отменено'),
|
||||
'declined': ('❌', 'Отклонено'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.DONUT:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'created': ('⏳', 'Создано'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'cancelled': ('❌', 'Отменено'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.LAVA:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'created': ('⏳', 'Создано'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'cancel': ('❌', 'Отменено'),
|
||||
'cancelled': ('❌', 'Отменено'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
return '❓', 'Неизвестно'
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from .subscription_modules import (
|
||||
devices_router,
|
||||
purchase_router,
|
||||
renewal_router,
|
||||
revoke_router,
|
||||
servers_router,
|
||||
status_router,
|
||||
tariff_switch_router,
|
||||
@@ -50,3 +51,4 @@ router.include_router(servers_router)
|
||||
router.include_router(autopay_router)
|
||||
router.include_router(daily_router)
|
||||
router.include_router(tariff_switch_router)
|
||||
router.include_router(revoke_router)
|
||||
|
||||
@@ -10,6 +10,7 @@ from .devices import router as devices_router
|
||||
from .multi_tariff import router as multi_tariff_router
|
||||
from .purchase import router as purchase_router
|
||||
from .renewal import router as renewal_router
|
||||
from .revoke import router as revoke_router
|
||||
from .servers import router as servers_router
|
||||
from .status import router as status_router
|
||||
from .tariff_switch import router as tariff_switch_router
|
||||
@@ -23,6 +24,7 @@ __all__ = [
|
||||
'multi_tariff_router',
|
||||
'purchase_router',
|
||||
'renewal_router',
|
||||
'revoke_router',
|
||||
'servers_router',
|
||||
'status_router',
|
||||
'tariff_switch_router',
|
||||
|
||||
@@ -828,6 +828,9 @@ async def get_device_price(
|
||||
response['discount_percent'] = devices_discount_percent
|
||||
response['discount_kopeks'] = discount_value
|
||||
response['base_total_price_kopeks'] = base_total_price
|
||||
response['original_price_per_device_kopeks'] = (
|
||||
base_total_price // devices if devices > 0 else 0
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -45,18 +45,26 @@ class EmailService:
|
||||
def use_tls(self) -> bool:
|
||||
return settings.SMTP_USE_TLS
|
||||
|
||||
@property
|
||||
def use_ssl(self) -> bool:
|
||||
# Port 465 always implies implicit TLS (SMTPS, RFC 8314).
|
||||
return settings.SMTP_USE_SSL or self.port == 465
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if SMTP is properly configured."""
|
||||
return settings.is_smtp_configured()
|
||||
|
||||
def _get_smtp_connection(self) -> smtplib.SMTP:
|
||||
"""Create and return SMTP connection."""
|
||||
smtp = smtplib.SMTP(self.host, self.port, timeout=30)
|
||||
smtp.ehlo()
|
||||
|
||||
if self.use_tls:
|
||||
smtp.starttls()
|
||||
if self.use_ssl:
|
||||
smtp: smtplib.SMTP = smtplib.SMTP_SSL(self.host, self.port, timeout=30)
|
||||
smtp.ehlo()
|
||||
else:
|
||||
smtp = smtplib.SMTP(self.host, self.port, timeout=30)
|
||||
smtp.ehlo()
|
||||
if self.use_tls:
|
||||
smtp.starttls()
|
||||
smtp.ehlo()
|
||||
|
||||
# Only attempt login if credentials are provided AND server supports AUTH
|
||||
if self.user and self.password:
|
||||
|
||||
+348
@@ -286,6 +286,10 @@ class Settings(BaseSettings):
|
||||
|
||||
DISPOSABLE_EMAIL_CHECK_ENABLED: bool = True
|
||||
|
||||
# Настройки перевыпуска подписки (revoke + regenerate link)
|
||||
SUBSCRIPTION_REVOKE_ENABLED: bool = True
|
||||
SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS: int = 900 # 15 minutes
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
@@ -616,6 +620,19 @@ class Settings(BaseSettings):
|
||||
SEVERPAY_RETURN_URL: str | None = None
|
||||
SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320
|
||||
|
||||
# Apple In-App Purchase
|
||||
APPLE_IAP_ENABLED: bool = False
|
||||
APPLE_IAP_KEY_ID: str | None = None
|
||||
APPLE_IAP_ISSUER_ID: str | None = None
|
||||
APPLE_IAP_BUNDLE_ID: str = 'com.app.client'
|
||||
APPLE_IAP_PRIVATE_KEY: str | None = None # .p8 key contents (PEM)
|
||||
APPLE_IAP_PRIVATE_KEY_PATH: str | None = None # Alternative: path to .p8 file
|
||||
APPLE_IAP_ENVIRONMENT: str = 'Production' # 'Sandbox' or 'Production'
|
||||
APPLE_IAP_WEBHOOK_PATH: str = '/apple-iap-webhook'
|
||||
APPLE_IAP_PRODUCTS: str = (
|
||||
'{"com.app.client.topup.100":10000,"com.app.client.topup.300":30000,"com.app.client.topup.500":50000}'
|
||||
)
|
||||
|
||||
# PayPear (paypear.ru)
|
||||
PAYPEAR_ENABLED: bool = False
|
||||
PAYPEAR_SHOP_ID: str | None = None
|
||||
@@ -668,6 +685,108 @@ class Settings(BaseSettings):
|
||||
AURAPAY_WEBHOOK_PATH: str = '/aurapay-webhook'
|
||||
AURAPAY_RETURN_URL: str | None = None
|
||||
AURAPAY_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
AURAPAY_SBP_ENABLED: bool = False
|
||||
AURAPAY_SBP_DISPLAY_NAME: str = 'СБП (AuraPay)'
|
||||
AURAPAY_CARD_ENABLED: bool = False
|
||||
AURAPAY_CARD_DISPLAY_NAME: str = 'Карта (AuraPay)'
|
||||
|
||||
# Antilopay (lk.antilopay.com)
|
||||
ANTILOPAY_ENABLED: bool = False
|
||||
ANTILOPAY_SECRET_ID: str | None = None
|
||||
ANTILOPAY_PRIVATE_KEY: str | None = None
|
||||
ANTILOPAY_PUBLIC_KEY: str | None = None
|
||||
ANTILOPAY_PROJECT_ID: str | None = None
|
||||
ANTILOPAY_DISPLAY_NAME: str = 'Antilopay'
|
||||
ANTILOPAY_PRODUCT_NAME: str = 'VPN подписка'
|
||||
ANTILOPAY_PRODUCT_TYPE: str = 'services'
|
||||
ANTILOPAY_CURRENCY: str = 'RUB'
|
||||
ANTILOPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
ANTILOPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
ANTILOPAY_WEBHOOK_PATH: str = '/antilopay-webhook'
|
||||
ANTILOPAY_RETURN_URL: str | None = None
|
||||
ANTILOPAY_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
ANTILOPAY_SBP_ENABLED: bool = False
|
||||
ANTILOPAY_SBP_DISPLAY_NAME: str = 'СБП (Antilopay)'
|
||||
ANTILOPAY_CARD_ENABLED: bool = False
|
||||
ANTILOPAY_CARD_DISPLAY_NAME: str = 'Карта (Antilopay)'
|
||||
ANTILOPAY_SBERPAY_ENABLED: bool = False
|
||||
ANTILOPAY_SBERPAY_DISPLAY_NAME: str = 'SberPay (Antilopay)'
|
||||
|
||||
# Jupiter (FPGate P2P v2.1, app.juppiter.tech)
|
||||
JUPITER_ENABLED: bool = False
|
||||
JUPITER_TOKEN: str | None = None
|
||||
JUPITER_SECRET: str | None = None
|
||||
JUPITER_BASE_URL: str = 'https://app.juppiter.tech'
|
||||
JUPITER_METHOD_ID: str | None = None
|
||||
JUPITER_METHOD_DESCRIPTION: str = 'SBP'
|
||||
JUPITER_DISPLAY_NAME: str = 'Jupiter'
|
||||
JUPITER_CURRENCY: str = 'RUB'
|
||||
JUPITER_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
JUPITER_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
JUPITER_WEBHOOK_PATH: str = '/jupiter-webhook'
|
||||
JUPITER_RETURN_URL: str | None = None
|
||||
JUPITER_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
JUPITER_FALLBACK_EMAIL: str = 'user@vpn.bot'
|
||||
JUPITER_FALLBACK_PHONE: str = '0000000000'
|
||||
JUPITER_FALLBACK_NAME: str = 'User'
|
||||
JUPITER_SBP_ENABLED: bool = False
|
||||
JUPITER_SBP_DISPLAY_NAME: str = 'СБП (Jupiter)'
|
||||
|
||||
# Donut (Donut P2P, gw.donut.business)
|
||||
DONUT_ENABLED: bool = False
|
||||
DONUT_TOKEN: str | None = None
|
||||
DONUT_SECRET: str | None = None
|
||||
DONUT_BASE_URL: str = 'https://gw.donut.business'
|
||||
DONUT_METHOD_ID: str | None = None
|
||||
DONUT_DISPLAY_NAME: str = 'Donut'
|
||||
DONUT_CURRENCY: str = 'RUB'
|
||||
DONUT_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
DONUT_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
DONUT_WEBHOOK_PATH: str = '/donut-webhook'
|
||||
DONUT_RETURN_URL: str | None = None
|
||||
DONUT_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
# Sub-методы Donut (description в PayIn запросе)
|
||||
DONUT_CARD_ENABLED: bool = False
|
||||
DONUT_CARD_DISPLAY_NAME: str = 'Карта (Donut)'
|
||||
DONUT_SBP_ENABLED: bool = False
|
||||
DONUT_SBP_DISPLAY_NAME: str = 'СБП (Donut)'
|
||||
DONUT_SBP_QR_ENABLED: bool = False
|
||||
DONUT_SBP_QR_DISPLAY_NAME: str = 'СБП QR (Donut)'
|
||||
|
||||
# Lava (Lava Business API, gate.lava.ru)
|
||||
LAVA_ENABLED: bool = False
|
||||
LAVA_BASE_URL: str = 'https://gate.lava.ru'
|
||||
LAVA_SHOP_ID: str | None = None # UUID проекта
|
||||
LAVA_SECRET_KEY: str | None = None # secret_key — для подписи запросов
|
||||
LAVA_WEBHOOK_SECRET: str | None = None # secret_key_2 — для проверки подписи webhook
|
||||
LAVA_DISPLAY_NAME: str = 'Lava'
|
||||
LAVA_CURRENCY: str = 'RUB'
|
||||
LAVA_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
LAVA_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
LAVA_WEBHOOK_PATH: str = '/lava-webhook'
|
||||
LAVA_RETURN_URL: str | None = None
|
||||
LAVA_PAYMENT_LIFETIME_MINUTES: int = 60 # макс 7200 минут (5 дней)
|
||||
# Sub-методы Lava (фильтр через includeService/excludeService на стороне Lava)
|
||||
LAVA_CARD_ENABLED: bool = False
|
||||
LAVA_CARD_DISPLAY_NAME: str = 'Карта (Lava)'
|
||||
LAVA_SBP_ENABLED: bool = False
|
||||
LAVA_SBP_DISPLAY_NAME: str = 'СБП (Lava)'
|
||||
|
||||
# Etoplatezhi (paymentpage.etoplatezhi.ru)
|
||||
ETOPLATEZHI_ENABLED: bool = False
|
||||
ETOPLATEZHI_PROJECT_ID: int | None = None
|
||||
ETOPLATEZHI_SECRET_KEY: str | None = None
|
||||
ETOPLATEZHI_DISPLAY_NAME: str = 'Etoplatezhi'
|
||||
ETOPLATEZHI_CURRENCY: str = 'RUB'
|
||||
ETOPLATEZHI_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
ETOPLATEZHI_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
ETOPLATEZHI_WEBHOOK_PATH: str = '/etoplatezhi-webhook'
|
||||
ETOPLATEZHI_RETURN_URL: str | None = None
|
||||
ETOPLATEZHI_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
ETOPLATEZHI_SBP_ENABLED: bool = False
|
||||
ETOPLATEZHI_SBP_DISPLAY_NAME: str = 'СБП (Etoplatezhi)'
|
||||
ETOPLATEZHI_CARD_ENABLED: bool = False
|
||||
ETOPLATEZHI_CARD_DISPLAY_NAME: str = 'Карта (Etoplatezhi)'
|
||||
|
||||
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
|
||||
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
|
||||
@@ -895,6 +1014,8 @@ class Settings(BaseSettings):
|
||||
SMTP_FROM_EMAIL: str | None = None
|
||||
SMTP_FROM_NAME: str = 'VPN Service'
|
||||
SMTP_USE_TLS: bool = True
|
||||
# Implicit TLS (SMTPS) — required for port 465. Auto-enabled when SMTP_PORT == 465.
|
||||
SMTP_USE_SSL: bool = False
|
||||
|
||||
# Ban System Integration (BedolagaBan monitoring)
|
||||
BAN_SYSTEM_ENABLED: bool = False
|
||||
@@ -1748,6 +1869,10 @@ class Settings(BaseSettings):
|
||||
def get_disabled_mode_device_limit(self) -> int | None:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_subscription_revoke_enabled(self) -> bool:
|
||||
"""Проверяет, включен ли перевыпуск подписки."""
|
||||
return self.SUBSCRIPTION_REVOKE_ENABLED
|
||||
|
||||
def is_multi_tariff_enabled(self) -> bool:
|
||||
"""Проверяет, включен ли мультитарифный режим."""
|
||||
return self.MULTI_TARIFF_ENABLED and self.SALES_MODE == 'tariffs'
|
||||
@@ -2027,6 +2152,34 @@ class Settings(BaseSettings):
|
||||
def get_severpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_severpay_display_name())
|
||||
|
||||
def is_apple_iap_enabled(self) -> bool:
|
||||
return (
|
||||
self.APPLE_IAP_ENABLED
|
||||
and self.APPLE_IAP_KEY_ID is not None
|
||||
and self.APPLE_IAP_ISSUER_ID is not None
|
||||
and (self.APPLE_IAP_PRIVATE_KEY is not None or self.APPLE_IAP_PRIVATE_KEY_PATH is not None)
|
||||
)
|
||||
|
||||
def get_apple_iap_products(self) -> dict[str, int]:
|
||||
"""Return mapping of Apple product ID -> kopeks amount."""
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
return _json.loads(self.APPLE_IAP_PRODUCTS)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_apple_iap_private_key(self) -> str | None:
|
||||
"""Return the .p8 private key contents."""
|
||||
if self.APPLE_IAP_PRIVATE_KEY:
|
||||
return self.APPLE_IAP_PRIVATE_KEY
|
||||
if self.APPLE_IAP_PRIVATE_KEY_PATH:
|
||||
try:
|
||||
return Path(self.APPLE_IAP_PRIVATE_KEY_PATH).read_text().strip()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def is_paypear_enabled(self) -> bool:
|
||||
return self.PAYPEAR_ENABLED and self.PAYPEAR_SHOP_ID is not None and self.PAYPEAR_SECRET_KEY is not None
|
||||
|
||||
@@ -2077,6 +2230,201 @@ class Settings(BaseSettings):
|
||||
def get_aurapay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_aurapay_display_name())
|
||||
|
||||
def is_aurapay_sbp_enabled(self) -> bool:
|
||||
return self.AURAPAY_SBP_ENABLED and self.is_aurapay_enabled()
|
||||
|
||||
def get_aurapay_sbp_display_name(self) -> str:
|
||||
name = (self.AURAPAY_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (AuraPay)'
|
||||
|
||||
def get_aurapay_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_aurapay_sbp_display_name())
|
||||
|
||||
def is_aurapay_card_enabled(self) -> bool:
|
||||
return self.AURAPAY_CARD_ENABLED and self.is_aurapay_enabled()
|
||||
|
||||
def get_aurapay_card_display_name(self) -> str:
|
||||
name = (self.AURAPAY_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта (AuraPay)'
|
||||
|
||||
def get_aurapay_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_aurapay_card_display_name())
|
||||
|
||||
def is_antilopay_enabled(self) -> bool:
|
||||
return (
|
||||
self.ANTILOPAY_ENABLED
|
||||
and self.ANTILOPAY_SECRET_ID is not None
|
||||
and self.ANTILOPAY_PRIVATE_KEY is not None
|
||||
and self.ANTILOPAY_PUBLIC_KEY is not None
|
||||
and self.ANTILOPAY_PROJECT_ID is not None
|
||||
)
|
||||
|
||||
def get_antilopay_display_name(self) -> str:
|
||||
name = (self.ANTILOPAY_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Antilopay'
|
||||
|
||||
def get_antilopay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_antilopay_display_name())
|
||||
|
||||
def is_antilopay_sbp_enabled(self) -> bool:
|
||||
return self.ANTILOPAY_SBP_ENABLED and self.is_antilopay_enabled()
|
||||
|
||||
def get_antilopay_sbp_display_name(self) -> str:
|
||||
name = (self.ANTILOPAY_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (Antilopay)'
|
||||
|
||||
def get_antilopay_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_antilopay_sbp_display_name())
|
||||
|
||||
def is_antilopay_card_enabled(self) -> bool:
|
||||
return self.ANTILOPAY_CARD_ENABLED and self.is_antilopay_enabled()
|
||||
|
||||
def get_antilopay_card_display_name(self) -> str:
|
||||
name = (self.ANTILOPAY_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта (Antilopay)'
|
||||
|
||||
def get_antilopay_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_antilopay_card_display_name())
|
||||
|
||||
def is_antilopay_sberpay_enabled(self) -> bool:
|
||||
return self.ANTILOPAY_SBERPAY_ENABLED and self.is_antilopay_enabled()
|
||||
|
||||
def get_antilopay_sberpay_display_name(self) -> str:
|
||||
name = (self.ANTILOPAY_SBERPAY_DISPLAY_NAME or '').strip()
|
||||
return name or 'SberPay (Antilopay)'
|
||||
|
||||
def get_antilopay_sberpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_antilopay_sberpay_display_name())
|
||||
|
||||
def is_jupiter_enabled(self) -> bool:
|
||||
return self.JUPITER_ENABLED and self.JUPITER_TOKEN is not None and self.JUPITER_SECRET is not None
|
||||
|
||||
def get_jupiter_display_name(self) -> str:
|
||||
name = (self.JUPITER_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Jupiter'
|
||||
|
||||
def get_jupiter_display_name_html(self) -> str:
|
||||
return html.escape(self.get_jupiter_display_name())
|
||||
|
||||
def is_jupiter_sbp_enabled(self) -> bool:
|
||||
return self.JUPITER_SBP_ENABLED and self.is_jupiter_enabled()
|
||||
|
||||
def get_jupiter_sbp_display_name(self) -> str:
|
||||
name = (self.JUPITER_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (Jupiter)'
|
||||
|
||||
def get_jupiter_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_jupiter_sbp_display_name())
|
||||
|
||||
def is_donut_enabled(self) -> bool:
|
||||
return self.DONUT_ENABLED and self.DONUT_TOKEN is not None and self.DONUT_SECRET is not None
|
||||
|
||||
def get_donut_display_name(self) -> str:
|
||||
name = (self.DONUT_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Donut'
|
||||
|
||||
def get_donut_display_name_html(self) -> str:
|
||||
return html.escape(self.get_donut_display_name())
|
||||
|
||||
def is_donut_card_enabled(self) -> bool:
|
||||
return self.DONUT_CARD_ENABLED and self.is_donut_enabled()
|
||||
|
||||
def get_donut_card_display_name(self) -> str:
|
||||
name = (self.DONUT_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта (Donut)'
|
||||
|
||||
def get_donut_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_donut_card_display_name())
|
||||
|
||||
def is_donut_sbp_enabled(self) -> bool:
|
||||
return self.DONUT_SBP_ENABLED and self.is_donut_enabled()
|
||||
|
||||
def get_donut_sbp_display_name(self) -> str:
|
||||
name = (self.DONUT_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (Donut)'
|
||||
|
||||
def get_donut_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_donut_sbp_display_name())
|
||||
|
||||
def is_donut_sbp_qr_enabled(self) -> bool:
|
||||
return self.DONUT_SBP_QR_ENABLED and self.is_donut_enabled()
|
||||
|
||||
def get_donut_sbp_qr_display_name(self) -> str:
|
||||
name = (self.DONUT_SBP_QR_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП QR (Donut)'
|
||||
|
||||
def get_donut_sbp_qr_display_name_html(self) -> str:
|
||||
return html.escape(self.get_donut_sbp_qr_display_name())
|
||||
|
||||
def is_lava_enabled(self) -> bool:
|
||||
return (
|
||||
self.LAVA_ENABLED
|
||||
and self.LAVA_SHOP_ID is not None
|
||||
and self.LAVA_SECRET_KEY is not None
|
||||
and self.LAVA_WEBHOOK_SECRET is not None
|
||||
)
|
||||
|
||||
def get_lava_display_name(self) -> str:
|
||||
name = (self.LAVA_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Lava'
|
||||
|
||||
def get_lava_display_name_html(self) -> str:
|
||||
return html.escape(self.get_lava_display_name())
|
||||
|
||||
def is_lava_card_enabled(self) -> bool:
|
||||
return self.LAVA_CARD_ENABLED and self.is_lava_enabled()
|
||||
|
||||
def get_lava_card_display_name(self) -> str:
|
||||
name = (self.LAVA_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта (Lava)'
|
||||
|
||||
def get_lava_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_lava_card_display_name())
|
||||
|
||||
def is_lava_sbp_enabled(self) -> bool:
|
||||
return self.LAVA_SBP_ENABLED and self.is_lava_enabled()
|
||||
|
||||
def get_lava_sbp_display_name(self) -> str:
|
||||
name = (self.LAVA_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (Lava)'
|
||||
|
||||
def get_lava_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_lava_sbp_display_name())
|
||||
|
||||
def is_etoplatezhi_enabled(self) -> bool:
|
||||
return (
|
||||
self.ETOPLATEZHI_ENABLED
|
||||
and self.ETOPLATEZHI_PROJECT_ID is not None
|
||||
and self.ETOPLATEZHI_SECRET_KEY is not None
|
||||
)
|
||||
|
||||
def get_etoplatezhi_display_name(self) -> str:
|
||||
name = (self.ETOPLATEZHI_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Etoplatezhi'
|
||||
|
||||
def get_etoplatezhi_display_name_html(self) -> str:
|
||||
return html.escape(self.get_etoplatezhi_display_name())
|
||||
|
||||
def is_etoplatezhi_sbp_enabled(self) -> bool:
|
||||
return self.ETOPLATEZHI_SBP_ENABLED and self.is_etoplatezhi_enabled()
|
||||
|
||||
def get_etoplatezhi_sbp_display_name(self) -> str:
|
||||
name = (self.ETOPLATEZHI_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (Etoplatezhi)'
|
||||
|
||||
def get_etoplatezhi_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_etoplatezhi_sbp_display_name())
|
||||
|
||||
def is_etoplatezhi_card_enabled(self) -> bool:
|
||||
return self.ETOPLATEZHI_CARD_ENABLED and self.is_etoplatezhi_enabled()
|
||||
|
||||
def get_etoplatezhi_card_display_name(self) -> str:
|
||||
name = (self.ETOPLATEZHI_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта (Etoplatezhi)'
|
||||
|
||||
def get_etoplatezhi_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_etoplatezhi_card_display_name())
|
||||
|
||||
def is_kassa_ai_sbp_enabled(self) -> bool:
|
||||
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -31,6 +31,8 @@ REAL_PAYMENT_METHODS = [
|
||||
PaymentMethod.PAYPEAR.value,
|
||||
PaymentMethod.OVERPAY.value,
|
||||
PaymentMethod.AURAPAY.value,
|
||||
PaymentMethod.ETOPLATEZHI.value,
|
||||
PaymentMethod.ANTILOPAY.value,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -162,10 +162,16 @@ class PaymentMethod(Enum):
|
||||
KASSA_AI = 'kassa_ai'
|
||||
RIOPAY = 'riopay'
|
||||
SEVERPAY = 'severpay'
|
||||
APPLE_IAP = 'apple_iap'
|
||||
PAYPEAR = 'paypear'
|
||||
ROLLYPAY = 'rollypay'
|
||||
OVERPAY = 'overpay'
|
||||
AURAPAY = 'aurapay'
|
||||
ETOPLATEZHI = 'etoplatezhi'
|
||||
ANTILOPAY = 'antilopay'
|
||||
JUPITER = 'jupiter'
|
||||
DONUT = 'donut'
|
||||
LAVA = 'lava'
|
||||
MANUAL = 'manual'
|
||||
BALANCE = 'balance'
|
||||
|
||||
@@ -329,6 +335,41 @@ class CryptoBotPayment(Base):
|
||||
return f'<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):
|
||||
__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})>'
|
||||
|
||||
|
||||
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):
|
||||
__tablename__ = 'promo_groups'
|
||||
|
||||
@@ -1646,6 +1999,7 @@ class Subscription(Base):
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
last_webhook_update_at = Column(AwareDateTime(), nullable=True)
|
||||
last_revoke_at = Column(AwareDateTime(), nullable=True)
|
||||
|
||||
remnawave_short_uuid = Column(String(255), nullable=True)
|
||||
remnawave_uuid = Column(String(255), nullable=True)
|
||||
|
||||
Vendored
+411
@@ -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
|
||||
Vendored
+339
@@ -57,6 +57,9 @@ class WebhookServer:
|
||||
|
||||
self.app.router.add_get('/health', self._health_check)
|
||||
|
||||
if settings.is_apple_iap_enabled():
|
||||
self.app.router.add_post(settings.APPLE_IAP_WEBHOOK_PATH, self._apple_iap_webhook_handler)
|
||||
|
||||
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_mulenpay_enabled():
|
||||
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
|
||||
@@ -64,6 +67,8 @@ class WebhookServer:
|
||||
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_freekassa_enabled():
|
||||
self.app.router.add_options(settings.FREEKASSA_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_apple_iap_enabled():
|
||||
self.app.router.add_options(settings.APPLE_IAP_WEBHOOK_PATH, self._options_handler)
|
||||
|
||||
logger.info('Webhook сервер настроен:')
|
||||
logger.info('Tribute webhook: POST', TRIBUTE_WEBHOOK_PATH=settings.TRIBUTE_WEBHOOK_PATH)
|
||||
@@ -76,6 +81,8 @@ class WebhookServer:
|
||||
logger.info('CryptoBot webhook: POST', CRYPTOBOT_WEBHOOK_PATH=settings.CRYPTOBOT_WEBHOOK_PATH)
|
||||
if settings.is_freekassa_enabled():
|
||||
logger.info('Freekassa webhook: POST', FREEKASSA_WEBHOOK_PATH=settings.FREEKASSA_WEBHOOK_PATH)
|
||||
if settings.is_apple_iap_enabled():
|
||||
logger.info('Apple IAP webhook: POST', APPLE_IAP_WEBHOOK_PATH=settings.APPLE_IAP_WEBHOOK_PATH)
|
||||
logger.info(' - Health check: GET /health')
|
||||
|
||||
return self.app
|
||||
@@ -491,3 +498,335 @@ class WebhookServer:
|
||||
except Exception as e:
|
||||
logger.error('Критическая ошибка обработки Freekassa webhook', error=e, exc_info=True)
|
||||
return web.Response(text='NO', status=500)
|
||||
|
||||
async def _apple_iap_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
"""Handle Apple App Store Server Notifications V2."""
|
||||
try:
|
||||
logger.info('Получен Apple IAP webhook', method=request.method, path=request.path)
|
||||
|
||||
raw_body = await request.read()
|
||||
if not raw_body:
|
||||
logger.warning('Пустой Apple IAP webhook')
|
||||
return web.Response(status=400)
|
||||
|
||||
try:
|
||||
body = json.loads(raw_body.decode('utf-8'))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
logger.error('Ошибка парсинга Apple IAP webhook', error=e)
|
||||
return web.Response(status=400)
|
||||
|
||||
signed_payload = body.get('signedPayload')
|
||||
if not signed_payload:
|
||||
logger.warning('No signedPayload in Apple webhook')
|
||||
return web.Response(status=400)
|
||||
|
||||
# Verify and decode the notification
|
||||
from app.external.apple_iap import AppleIAPService
|
||||
|
||||
apple_service = AppleIAPService()
|
||||
notification = apple_service.verify_notification(signed_payload)
|
||||
if not notification:
|
||||
logger.warning('Apple webhook signature verification failed')
|
||||
return web.Response(status=403)
|
||||
|
||||
notification_type = notification.get('notificationType', '')
|
||||
subtype = notification.get('subtype', '')
|
||||
|
||||
# Verify notification environment matches our config
|
||||
# FIX 11: removed dead initial assignment of expected_envs
|
||||
notif_env = notification.get('data', {}).get('environment', '')
|
||||
if settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
||||
expected_envs = {'Production', 'Sandbox'} # Sandbox for App Review
|
||||
else:
|
||||
expected_envs = {'Sandbox'}
|
||||
if notif_env and notif_env not in expected_envs:
|
||||
logger.warning(
|
||||
'Apple webhook environment mismatch',
|
||||
expected=settings.APPLE_IAP_ENVIRONMENT,
|
||||
received=notif_env,
|
||||
)
|
||||
return web.Response(status=200) # ACK but ignore
|
||||
|
||||
logger.info(
|
||||
'Apple notification received',
|
||||
notification_type=notification_type,
|
||||
subtype=subtype,
|
||||
environment=notif_env,
|
||||
)
|
||||
|
||||
# Handle notification types
|
||||
if notification_type == 'TEST':
|
||||
logger.info('Apple TEST notification received -- OK')
|
||||
return web.Response(status=200)
|
||||
|
||||
if notification_type == 'REFUND':
|
||||
await self._handle_apple_refund(notification, apple_service)
|
||||
return web.Response(status=200)
|
||||
|
||||
if notification_type == 'REFUND_REVERSED':
|
||||
await self._handle_apple_refund_reversed(notification)
|
||||
return web.Response(status=200)
|
||||
|
||||
if notification_type == 'CONSUMPTION_REQUEST':
|
||||
await self._handle_apple_consumption_request(notification, apple_service)
|
||||
return web.Response(status=200)
|
||||
|
||||
if notification_type in ('ONE_TIME_CHARGE', 'REFUND_DECLINED'):
|
||||
logger.info('Apple notification logged', notification_type=notification_type)
|
||||
return web.Response(status=200)
|
||||
|
||||
logger.info('Unhandled Apple notification type', notification_type=notification_type)
|
||||
return web.Response(status=200)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Критическая ошибка обработки Apple IAP webhook', error=e, exc_info=True)
|
||||
return web.Response(status=500)
|
||||
|
||||
async def _handle_apple_refund(self, notification: dict, apple_service) -> None:
|
||||
"""Handle REFUND notification -- deduct credited balance."""
|
||||
try:
|
||||
data = notification.get('data', {})
|
||||
signed_txn_info = data.get('signedTransactionInfo')
|
||||
if not signed_txn_info:
|
||||
logger.warning('No signedTransactionInfo in REFUND notification')
|
||||
return
|
||||
|
||||
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
|
||||
if not txn_info:
|
||||
logger.warning('Failed to verify REFUND transaction info')
|
||||
return
|
||||
|
||||
apple_txn_id = str(txn_info.get('transactionId') or '')
|
||||
original_txn_id = str(txn_info.get('originalTransactionId') or '')
|
||||
product_id = txn_info.get('productId', '')
|
||||
|
||||
from app.database.crud.apple_iap import (
|
||||
mark_apple_transaction_refunded,
|
||||
)
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
|
||||
lookup_id = original_txn_id or apple_txn_id
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
from app.database.crud.apple_iap import get_apple_transaction_by_transaction_id_for_update
|
||||
|
||||
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, lookup_id)
|
||||
if not apple_txn:
|
||||
# Try the other ID
|
||||
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, apple_txn_id)
|
||||
|
||||
if not apple_txn:
|
||||
logger.warning(
|
||||
'Apple REFUND: transaction not found',
|
||||
transaction_id=apple_txn_id,
|
||||
original_transaction_id=original_txn_id,
|
||||
)
|
||||
return
|
||||
|
||||
if apple_txn.status == 'refunded':
|
||||
logger.info('Apple REFUND: already refunded', transaction_id=lookup_id)
|
||||
return
|
||||
|
||||
if apple_txn.environment == 'Sandbox' and settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
||||
logger.info(
|
||||
'Apple REFUND: ignoring sandbox refund on production',
|
||||
transaction_id=lookup_id,
|
||||
user_id=apple_txn.user_id,
|
||||
)
|
||||
return
|
||||
|
||||
# FIX 6: Lock user row with FOR UPDATE before reading balance
|
||||
# to prevent race condition in min() balance cap calculation
|
||||
user = await lock_user_for_pricing(db, apple_txn.user_id)
|
||||
if not user:
|
||||
logger.error('Apple REFUND: user not found', user_id=apple_txn.user_id)
|
||||
return
|
||||
|
||||
# Cap deduction to current balance to prevent negative balance
|
||||
refund_amount = min(apple_txn.amount_kopeks, user.balance_kopeks)
|
||||
if refund_amount < apple_txn.amount_kopeks:
|
||||
logger.warning(
|
||||
'Apple REFUND: partial balance deduction (user already spent funds)',
|
||||
full_amount=apple_txn.amount_kopeks,
|
||||
deducted=refund_amount,
|
||||
user_balance=user.balance_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
# Disable active subscriptions -- funds were spent and refunded
|
||||
from app.database.crud.subscription import (
|
||||
deactivate_subscription,
|
||||
get_active_subscriptions_by_user_id,
|
||||
)
|
||||
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
||||
for sub in active_subs:
|
||||
await deactivate_subscription(db, sub, commit=False)
|
||||
logger.warning(
|
||||
'Apple REFUND: disabled subscription due to insufficient balance',
|
||||
subscription_id=sub.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
if refund_amount > 0:
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
await subtract_user_balance(
|
||||
db=db,
|
||||
user=user,
|
||||
amount_kopeks=refund_amount,
|
||||
description=f'Возврат Apple IAP: {product_id}',
|
||||
create_transaction=True,
|
||||
payment_method=PaymentMethod.APPLE_IAP,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
await mark_apple_transaction_refunded(db, apple_txn.transaction_id)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Apple REFUND processed',
|
||||
transaction_id=apple_txn.transaction_id,
|
||||
amount_kopeks=apple_txn.amount_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error handling Apple REFUND', error=e, exc_info=True)
|
||||
|
||||
async def _handle_apple_refund_reversed(self, notification: dict) -> None:
|
||||
"""Handle REFUND_REVERSED -- re-credit balance that was previously deducted."""
|
||||
try:
|
||||
data = notification.get('data', {})
|
||||
signed_txn_info = data.get('signedTransactionInfo')
|
||||
if not signed_txn_info:
|
||||
logger.warning('No signedTransactionInfo in REFUND_REVERSED notification')
|
||||
return
|
||||
|
||||
from app.external.apple_iap import AppleIAPService
|
||||
|
||||
apple_service = AppleIAPService()
|
||||
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
|
||||
if not txn_info:
|
||||
logger.warning('Failed to verify REFUND_REVERSED transaction info')
|
||||
return
|
||||
|
||||
apple_txn_id = str(txn_info.get('transactionId') or '')
|
||||
original_txn_id = str(txn_info.get('originalTransactionId') or '')
|
||||
product_id = txn_info.get('productId', '')
|
||||
|
||||
from app.database.crud.apple_iap import (
|
||||
get_apple_transaction_by_transaction_id_for_update,
|
||||
)
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import PaymentMethod
|
||||
|
||||
lookup_id = original_txn_id or apple_txn_id
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# FIX 7: Use FOR UPDATE lock on apple_transactions row
|
||||
# before checking status to prevent idempotency race
|
||||
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, lookup_id)
|
||||
if not apple_txn:
|
||||
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, apple_txn_id)
|
||||
|
||||
if not apple_txn:
|
||||
logger.warning(
|
||||
'Apple REFUND_REVERSED: transaction not found',
|
||||
transaction_id=apple_txn_id,
|
||||
)
|
||||
return
|
||||
|
||||
if apple_txn.status != 'refunded':
|
||||
logger.info(
|
||||
'Apple REFUND_REVERSED: transaction not in refunded state',
|
||||
transaction_id=lookup_id,
|
||||
status=apple_txn.status,
|
||||
)
|
||||
return
|
||||
|
||||
if apple_txn.environment == 'Sandbox' and settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
||||
logger.info(
|
||||
'Apple REFUND_REVERSED: ignoring sandbox on production',
|
||||
transaction_id=lookup_id,
|
||||
)
|
||||
return
|
||||
|
||||
user = await get_user_by_id(db, apple_txn.user_id)
|
||||
if not user:
|
||||
logger.error('Apple REFUND_REVERSED: user not found', user_id=apple_txn.user_id)
|
||||
return
|
||||
|
||||
# Re-credit the balance
|
||||
await add_user_balance(
|
||||
db=db,
|
||||
user=user,
|
||||
amount_kopeks=apple_txn.amount_kopeks,
|
||||
description=f'Отмена возврата Apple IAP: {product_id}',
|
||||
payment_method=PaymentMethod.APPLE_IAP,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
apple_txn.status = 'verified'
|
||||
apple_txn.refunded_at = None
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Apple REFUND_REVERSED processed -- balance re-credited',
|
||||
transaction_id=lookup_id,
|
||||
amount_kopeks=apple_txn.amount_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error handling Apple REFUND_REVERSED', error=e, exc_info=True)
|
||||
|
||||
async def _handle_apple_consumption_request(self, notification: dict, apple_service) -> None:
|
||||
"""Handle CONSUMPTION_REQUEST -- send consumption info to Apple."""
|
||||
try:
|
||||
data = notification.get('data', {})
|
||||
signed_txn_info = data.get('signedTransactionInfo')
|
||||
if not signed_txn_info:
|
||||
logger.warning('No signedTransactionInfo in CONSUMPTION_REQUEST')
|
||||
return
|
||||
|
||||
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
|
||||
if not txn_info:
|
||||
logger.warning('Failed to verify CONSUMPTION_REQUEST transaction info')
|
||||
return
|
||||
|
||||
apple_txn_id = str(txn_info.get('transactionId') or '')
|
||||
environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT)
|
||||
|
||||
from app.database.crud.apple_iap import get_apple_transaction_by_transaction_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
apple_txn = await get_apple_transaction_by_transaction_id(db, apple_txn_id)
|
||||
|
||||
# Determine if balance was consumed (spent on subscriptions)
|
||||
# consumptionStatus: 0 = undeclared, 1 = not consumed, 2 = partially consumed, 3 = fully consumed
|
||||
consumption_status = 0
|
||||
if apple_txn and apple_txn.status == 'verified':
|
||||
consumption_status = 3 # Balance was credited and likely spent
|
||||
|
||||
# customerConsented must be false -- we cannot prompt the user
|
||||
# in a server-to-server webhook. Apple accepts the response
|
||||
# regardless, but the consumption data weight may be lower.
|
||||
await apple_service.send_consumption_info(
|
||||
transaction_id=apple_txn_id,
|
||||
customer_consented=False,
|
||||
consumption_status=consumption_status,
|
||||
delivery_status=0, # 0 = delivered
|
||||
platform=1, # 1 = Apple
|
||||
environment=environment,
|
||||
)
|
||||
|
||||
logger.info('Apple CONSUMPTION_REQUEST handled', transaction_id=apple_txn_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error handling Apple CONSUMPTION_REQUEST', error=e, exc_info=True)
|
||||
|
||||
@@ -175,6 +175,7 @@ async def _build_notification_preview_message(language: str, notification_type:
|
||||
message = template.format(
|
||||
end_date=(now - timedelta(days=1)).strftime('%d.%m.%Y %H:%M'),
|
||||
price=price_30_days,
|
||||
tariff_label='',
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -213,6 +214,7 @@ async def _build_notification_preview_message(language: str, notification_type:
|
||||
percent=percent,
|
||||
expires_at=(now + timedelta(hours=valid_hours)).strftime('%d.%m.%Y %H:%M'),
|
||||
trigger_days=3,
|
||||
tariff_label='',
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -258,6 +260,7 @@ async def _build_notification_preview_message(language: str, notification_type:
|
||||
percent=percent,
|
||||
trigger_days=trigger_days,
|
||||
expires_at=(now + timedelta(hours=valid_hours)).strftime('%d.%m.%Y %H:%M'),
|
||||
tariff_label='',
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
|
||||
@@ -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')
|
||||
@@ -39,6 +39,7 @@ async def _create_aurapay_payment_and_respond(
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method_type: str | None = None,
|
||||
):
|
||||
"""
|
||||
Common logic for creating AuraPay payment and sending response.
|
||||
@@ -61,6 +62,7 @@ async def _create_aurapay_payment_and_respond(
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
if not result:
|
||||
@@ -179,6 +181,11 @@ async def process_aurapay_payment_amount(
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'aurapay')
|
||||
# aurapay_sbp → 'sbp', aurapay_card → 'card', aurapay → None
|
||||
payment_method_type = _extract_service_type(payment_method)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_aurapay_payment_and_respond(
|
||||
@@ -187,19 +194,30 @@ async def process_aurapay_payment_amount(
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_aurapay_topup(
|
||||
AURAPAY_PAYMENT_METHODS = {'aurapay', 'aurapay_sbp', 'aurapay_card'}
|
||||
|
||||
AURAPAY_SERVICE_MAP: dict[str, str | None] = {
|
||||
'aurapay': None,
|
||||
'aurapay_sbp': 'sbp',
|
||||
'aurapay_card': 'card',
|
||||
}
|
||||
|
||||
|
||||
def _extract_service_type(payment_method: str) -> str | None:
|
||||
return AURAPAY_SERVICE_MAP.get(payment_method)
|
||||
|
||||
|
||||
async def _start_aurapay_topup_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""
|
||||
Start AuraPay top-up process - ask for amount.
|
||||
"""
|
||||
"""Common logic for starting AuraPay top-up (generic / SBP / card)."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
@@ -213,11 +231,18 @@ async def start_aurapay_topup(
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='aurapay')
|
||||
await state.update_data(payment_method=payment_method)
|
||||
|
||||
min_amount = settings.AURAPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.AURAPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_aurapay_display_name()
|
||||
|
||||
# Choose display name based on sub-method
|
||||
if payment_method == 'aurapay_sbp':
|
||||
display_name = settings.get_aurapay_sbp_display_name()
|
||||
elif payment_method == 'aurapay_card':
|
||||
display_name = settings.get_aurapay_card_display_name()
|
||||
else:
|
||||
display_name = settings.get_aurapay_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -245,3 +270,33 @@ async def start_aurapay_topup(
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_aurapay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_aurapay_topup_impl(callback, db_user, state, 'aurapay')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_aurapay_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_aurapay_topup_impl(callback, db_user, state, 'aurapay_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_aurapay_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_aurapay_topup_impl(callback, db_user, state, 'aurapay_card')
|
||||
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -170,13 +170,48 @@ async def route_payment_by_method(
|
||||
await process_overpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'aurapay':
|
||||
if payment_method in ('aurapay', 'aurapay_sbp', 'aurapay_card'):
|
||||
from .aurapay import process_aurapay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_aurapay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('etoplatezhi', 'etoplatezhi_sbp', 'etoplatezhi_card'):
|
||||
from .etoplatezhi import process_etoplatezhi_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_etoplatezhi_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('antilopay', 'antilopay_sbp', 'antilopay_card', 'antilopay_sberpay'):
|
||||
from .antilopay import process_antilopay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_antilopay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('jupiter', 'jupiter_sbp'):
|
||||
from .jupiter import process_jupiter_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_jupiter_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('donut', 'donut_card', 'donut_sbp', 'donut_sbp_qr'):
|
||||
from .donut import process_donut_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_donut_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('lava', 'lava_card', 'lava_sbp'):
|
||||
from .lava import process_lava_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_lava_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'riopay':
|
||||
from .riopay import process_riopay_payment_amount
|
||||
|
||||
@@ -768,9 +803,52 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(start_overpay_topup, F.data == 'topup_overpay')
|
||||
|
||||
from .aurapay import start_aurapay_topup
|
||||
from .aurapay import start_aurapay_card_topup, start_aurapay_sbp_topup, start_aurapay_topup
|
||||
|
||||
dp.callback_query.register(start_aurapay_topup, F.data == 'topup_aurapay')
|
||||
dp.callback_query.register(start_aurapay_sbp_topup, F.data == 'topup_aurapay_sbp')
|
||||
dp.callback_query.register(start_aurapay_card_topup, F.data == 'topup_aurapay_card')
|
||||
|
||||
from .etoplatezhi import start_etoplatezhi_card_topup, start_etoplatezhi_sbp_topup, start_etoplatezhi_topup
|
||||
|
||||
dp.callback_query.register(start_etoplatezhi_topup, F.data == 'topup_etoplatezhi')
|
||||
dp.callback_query.register(start_etoplatezhi_sbp_topup, F.data == 'topup_etoplatezhi_sbp')
|
||||
dp.callback_query.register(start_etoplatezhi_card_topup, F.data == 'topup_etoplatezhi_card')
|
||||
|
||||
from .antilopay import (
|
||||
start_antilopay_card_topup,
|
||||
start_antilopay_sberpay_topup,
|
||||
start_antilopay_sbp_topup,
|
||||
start_antilopay_topup,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_antilopay_topup, F.data == 'topup_antilopay')
|
||||
dp.callback_query.register(start_antilopay_sbp_topup, F.data == 'topup_antilopay_sbp')
|
||||
dp.callback_query.register(start_antilopay_card_topup, F.data == 'topup_antilopay_card')
|
||||
dp.callback_query.register(start_antilopay_sberpay_topup, F.data == 'topup_antilopay_sberpay')
|
||||
|
||||
from .jupiter import start_jupiter_sbp_topup, start_jupiter_topup
|
||||
|
||||
dp.callback_query.register(start_jupiter_topup, F.data == 'topup_jupiter')
|
||||
dp.callback_query.register(start_jupiter_sbp_topup, F.data == 'topup_jupiter_sbp')
|
||||
|
||||
from .donut import (
|
||||
start_donut_card_topup,
|
||||
start_donut_sbp_qr_topup,
|
||||
start_donut_sbp_topup,
|
||||
start_donut_topup,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_donut_topup, F.data == 'topup_donut')
|
||||
dp.callback_query.register(start_donut_card_topup, F.data == 'topup_donut_card')
|
||||
dp.callback_query.register(start_donut_sbp_topup, F.data == 'topup_donut_sbp')
|
||||
dp.callback_query.register(start_donut_sbp_qr_topup, F.data == 'topup_donut_sbp_qr')
|
||||
|
||||
from .lava import start_lava_card_topup, start_lava_sbp_topup, start_lava_topup
|
||||
|
||||
dp.callback_query.register(start_lava_topup, F.data == 'topup_lava')
|
||||
dp.callback_query.register(start_lava_card_topup, F.data == 'topup_lava_card')
|
||||
dp.callback_query.register(start_lava_sbp_topup, F.data == 'topup_lava_sbp')
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
|
||||
|
||||
+47
-1
@@ -49,7 +49,11 @@ from app.services.pinned_message_service import (
|
||||
get_active_pinned_message,
|
||||
)
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.referral_service import process_referral_registration, save_pending_referral
|
||||
from app.services.referral_service import (
|
||||
process_referral_registration,
|
||||
save_pending_campaign,
|
||||
save_pending_referral,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.services.web_auth_service import WEB_AUTH_TOKEN_MIN_LENGTH, link_web_auth_token
|
||||
@@ -386,6 +390,17 @@ async def _apply_campaign_bonus_if_needed(
|
||||
if not result.success:
|
||||
return None
|
||||
|
||||
# Bot-flow successfully applied the campaign — clear the Redis pending entry
|
||||
# (set in cmd_start as a fallback for the cabinet WebApp path) so it isn't
|
||||
# re-evaluated on a subsequent cabinet login.
|
||||
try:
|
||||
from app.services.referral_service import clear_pending_campaign
|
||||
|
||||
if getattr(user, 'telegram_id', None):
|
||||
await clear_pending_campaign(user.telegram_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if result.bonus_type == 'balance':
|
||||
amount_text = texts.format_price(result.balance_kopeks)
|
||||
return texts.CAMPAIGN_BONUS_BALANCE.format(
|
||||
@@ -724,6 +739,23 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
start_parameter=campaign.start_parameter,
|
||||
)
|
||||
await state.update_data(campaign_id=campaign.id)
|
||||
# Persist campaign to Redis immediately so it survives if user opens
|
||||
# miniapp/cabinet (via Telegram menu button) before completing the
|
||||
# bot registration flow. Mirrors the pending_referral mechanism.
|
||||
# Only for new users — existing users already had attribution applied.
|
||||
if not db_user:
|
||||
try:
|
||||
await save_pending_campaign(
|
||||
message.from_user.id,
|
||||
campaign.start_parameter,
|
||||
campaign.id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
'Failed to persist pending campaign',
|
||||
campaign_id=campaign.id,
|
||||
error=exc,
|
||||
)
|
||||
if campaign.partner_user_id:
|
||||
await state.update_data(referrer_id=campaign.partner_user_id)
|
||||
logger.info(
|
||||
@@ -2416,6 +2448,20 @@ async def required_sub_channel_check(
|
||||
campaign_id=campaign.id,
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
)
|
||||
# Mirror save in Redis so cabinet WebApp auth can pick it up
|
||||
# if user opens miniapp before completing registration.
|
||||
try:
|
||||
await save_pending_campaign(
|
||||
query.from_user.id,
|
||||
campaign.start_parameter,
|
||||
campaign.id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
'Failed to persist pending campaign after channel check',
|
||||
campaign_id=campaign.id,
|
||||
error=exc,
|
||||
)
|
||||
else:
|
||||
state_data['referral_code'] = pending_start_payload
|
||||
logger.info(
|
||||
|
||||
@@ -406,11 +406,37 @@ async def get_apps_for_platform_async(device_type: str, language: str = 'ru') ->
|
||||
|
||||
def normalize_app(app: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize Remnawave app dict to a unified format with blocks."""
|
||||
|
||||
# Extract urlScheme from blocks if not present at root level
|
||||
url_scheme = app.get('urlScheme', '')
|
||||
|
||||
if not url_scheme:
|
||||
# Try to extract from subscriptionLink button in blocks
|
||||
blocks = app.get('blocks', [])
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
buttons = block.get('buttons', [])
|
||||
for btn in buttons:
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
if btn.get('type') == 'subscriptionLink':
|
||||
link = btn.get('link', '') or btn.get('url', '')
|
||||
if '{{SUBSCRIPTION_LINK}}' in link:
|
||||
url_scheme = link.split('{{SUBSCRIPTION_LINK}}')[0]
|
||||
break
|
||||
if url_scheme:
|
||||
break
|
||||
|
||||
# Validate extracted scheme contains ://
|
||||
if url_scheme and '://' not in url_scheme:
|
||||
url_scheme = ''
|
||||
|
||||
return {
|
||||
'id': app.get('id', app.get('name', 'unknown')),
|
||||
'name': app.get('name', ''),
|
||||
'isFeatured': app.get('featured', app.get('isFeatured', False)),
|
||||
'urlScheme': app.get('urlScheme', ''),
|
||||
'urlScheme': url_scheme,
|
||||
'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False),
|
||||
'blocks': app.get('blocks', []),
|
||||
'_raw': app,
|
||||
|
||||
@@ -132,6 +132,16 @@ def _build_subscription_detail_keyboard(sub_id: int, sub=None) -> types.InlineKe
|
||||
if is_inactive:
|
||||
buttons.append([types.InlineKeyboardButton(text='🗑 Удалить подписку', callback_data=f'sub_del:{sub_id}')])
|
||||
|
||||
if not is_inactive and settings.is_subscription_revoke_enabled():
|
||||
buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🔄 Перевыпустить',
|
||||
callback_data=f'sr:{sub_id}',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
buttons.append([types.InlineKeyboardButton(text='◀️ К списку подписок', callback_data='my_subscriptions')])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
@@ -4141,6 +4141,17 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(handle_change_devices_menu, F.data.startswith('change_devices_menu:'))
|
||||
dp.callback_query.register(handle_device_management_menu, F.data.startswith('device_management:'))
|
||||
|
||||
# Subscription revoke (reissue)
|
||||
from app.handlers.subscription.revoke import (
|
||||
confirm_subscription_revoke,
|
||||
start_multi_revoke,
|
||||
start_subscription_revoke,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_subscription_revoke, F.data == 'subscription_revoke')
|
||||
dp.callback_query.register(confirm_subscription_revoke, F.data == 'subscription_revoke_confirm')
|
||||
dp.callback_query.register(start_multi_revoke, F.data.startswith('sr:'))
|
||||
|
||||
dp.callback_query.register(show_trial_offer, F.data == 'menu_trial')
|
||||
|
||||
dp.callback_query.register(activate_trial, F.data == 'trial_activate')
|
||||
|
||||
@@ -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
@@ -8,6 +8,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import PERIOD_PRICES, settings
|
||||
from app.database.models import User
|
||||
from app.handlers.subscription.common import (
|
||||
build_redirect_link,
|
||||
create_deep_link,
|
||||
get_localized_value,
|
||||
resolve_button_url,
|
||||
)
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
@@ -1844,7 +1850,35 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_aurapay_enabled():
|
||||
if settings.is_aurapay_sbp_enabled():
|
||||
sbp_name = settings.get_aurapay_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_AURAPAY_SBP', f'📱 {sbp_name}'),
|
||||
callback_data=_build_callback('aurapay_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_aurapay_card_enabled():
|
||||
card_name = settings.get_aurapay_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_AURAPAY_CARD', f'💳 {card_name}'),
|
||||
callback_data=_build_callback('aurapay_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if (
|
||||
settings.is_aurapay_enabled()
|
||||
and not settings.is_aurapay_sbp_enabled()
|
||||
and not settings.is_aurapay_card_enabled()
|
||||
):
|
||||
aurapay_name = settings.get_aurapay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
@@ -1856,6 +1890,212 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_etoplatezhi_sbp_enabled():
|
||||
sbp_name = settings.get_etoplatezhi_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ETOPLATEZHI_SBP', f'📱 {sbp_name}'),
|
||||
callback_data=_build_callback('etoplatezhi_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_etoplatezhi_card_enabled():
|
||||
card_name = settings.get_etoplatezhi_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ETOPLATEZHI_CARD', f'💳 {card_name}'),
|
||||
callback_data=_build_callback('etoplatezhi_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if (
|
||||
settings.is_etoplatezhi_enabled()
|
||||
and not settings.is_etoplatezhi_sbp_enabled()
|
||||
and not settings.is_etoplatezhi_card_enabled()
|
||||
):
|
||||
etoplatezhi_name = settings.get_etoplatezhi_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ETOPLATEZHI', f'💳 {etoplatezhi_name}'),
|
||||
callback_data=_build_callback('etoplatezhi'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_antilopay_sbp_enabled():
|
||||
sbp_name = settings.get_antilopay_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ANTILOPAY_SBP', f'📱 {sbp_name}'),
|
||||
callback_data=_build_callback('antilopay_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_antilopay_card_enabled():
|
||||
card_name = settings.get_antilopay_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ANTILOPAY_CARD', f'💳 {card_name}'),
|
||||
callback_data=_build_callback('antilopay_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_antilopay_sberpay_enabled():
|
||||
sberpay_name = settings.get_antilopay_sberpay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ANTILOPAY_SBERPAY', f'💳 {sberpay_name}'),
|
||||
callback_data=_build_callback('antilopay_sberpay'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if (
|
||||
settings.is_antilopay_enabled()
|
||||
and not settings.is_antilopay_sbp_enabled()
|
||||
and not settings.is_antilopay_card_enabled()
|
||||
and not settings.is_antilopay_sberpay_enabled()
|
||||
):
|
||||
antilopay_name = settings.get_antilopay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ANTILOPAY', f'💳 {antilopay_name}'),
|
||||
callback_data=_build_callback('antilopay'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_jupiter_sbp_enabled():
|
||||
jupiter_sbp_name = settings.get_jupiter_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_JUPITER_SBP', f'📱 {jupiter_sbp_name}'),
|
||||
callback_data=_build_callback('jupiter_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_jupiter_enabled() and not settings.is_jupiter_sbp_enabled():
|
||||
jupiter_name = settings.get_jupiter_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_JUPITER', f'🪐 {jupiter_name}'),
|
||||
callback_data=_build_callback('jupiter'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_donut_card_enabled():
|
||||
donut_card_name = settings.get_donut_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_DONUT_CARD', f'💳 {donut_card_name}'),
|
||||
callback_data=_build_callback('donut_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_donut_sbp_enabled():
|
||||
donut_sbp_name = settings.get_donut_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_DONUT_SBP', f'📱 {donut_sbp_name}'),
|
||||
callback_data=_build_callback('donut_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_donut_sbp_qr_enabled():
|
||||
donut_qr_name = settings.get_donut_sbp_qr_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_DONUT_SBP_QR', f'🏦 {donut_qr_name}'),
|
||||
callback_data=_build_callback('donut_sbp_qr'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if (
|
||||
settings.is_donut_enabled()
|
||||
and not settings.is_donut_card_enabled()
|
||||
and not settings.is_donut_sbp_enabled()
|
||||
and not settings.is_donut_sbp_qr_enabled()
|
||||
):
|
||||
donut_name = settings.get_donut_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_DONUT', f'🍩 {donut_name}'),
|
||||
callback_data=_build_callback('donut'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_lava_card_enabled():
|
||||
lava_card_name = settings.get_lava_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_LAVA_CARD', f'💳 {lava_card_name}'),
|
||||
callback_data=_build_callback('lava_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_lava_sbp_enabled():
|
||||
lava_sbp_name = settings.get_lava_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_LAVA_SBP', f'📱 {lava_sbp_name}'),
|
||||
callback_data=_build_callback('lava_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_lava_enabled() and not settings.is_lava_card_enabled() and not settings.is_lava_sbp_enabled():
|
||||
lava_name = settings.get_lava_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_LAVA', f'🌋 {lava_name}'),
|
||||
callback_data=_build_callback('lava'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_support_topup_enabled():
|
||||
keyboard.append(
|
||||
[
|
||||
@@ -2554,9 +2794,6 @@ def get_device_selection_keyboard(
|
||||
platforms: list[dict] | None = None,
|
||||
sub_id: int | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
from app.handlers.subscription.common import get_localized_value
|
||||
|
||||
texts = get_texts(language)
|
||||
back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription'
|
||||
|
||||
@@ -2609,8 +2846,6 @@ def get_connection_guide_keyboard(
|
||||
has_other_apps: bool = False,
|
||||
sub_id: int | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.handlers.subscription.common import create_deep_link, get_localized_value, resolve_button_url
|
||||
|
||||
texts = get_texts(language)
|
||||
back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription'
|
||||
|
||||
@@ -2623,6 +2858,10 @@ def get_connection_guide_keyboard(
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
btn_type = btn.get('type', '')
|
||||
# Support both 'external' and 'externalLink' for backward compatibility
|
||||
if btn_type == 'external':
|
||||
btn_type = 'externalLink'
|
||||
|
||||
btn_text = btn.get('text', {})
|
||||
if isinstance(btn_text, dict):
|
||||
btn_text = get_localized_value(btn_text, language)
|
||||
@@ -2644,9 +2883,26 @@ def get_connection_guide_keyboard(
|
||||
]
|
||||
)
|
||||
elif btn_type == 'subscriptionLink':
|
||||
# First try to resolve the button's URL template
|
||||
url = resolved_url or resolve_button_url(btn_url, subscription_url)
|
||||
deep_link = create_deep_link(app.get('_raw', app), subscription_url)
|
||||
final_url = deep_link or url or subscription_url
|
||||
|
||||
# If button has no template, try deep link
|
||||
if not btn_url or '{{SUBSCRIPTION_LINK}}' not in btn_url:
|
||||
deep_link = create_deep_link(app.get('_raw', app), subscription_url)
|
||||
final_url = deep_link or url or subscription_url
|
||||
else:
|
||||
final_url = url or subscription_url
|
||||
|
||||
# Telegram doesn't support custom URL schemes — wrap with redirect
|
||||
if final_url and not final_url.startswith(('http://', 'https://')):
|
||||
template = settings.get_happ_cryptolink_redirect_template()
|
||||
if template:
|
||||
wrapped_url = build_redirect_link(final_url, template)
|
||||
if wrapped_url:
|
||||
final_url = wrapped_url
|
||||
else:
|
||||
final_url = subscription_url
|
||||
|
||||
if final_url:
|
||||
keyboard.append(
|
||||
[
|
||||
@@ -2976,6 +3232,16 @@ def get_updated_subscription_settings_keyboard(
|
||||
]
|
||||
)
|
||||
|
||||
if settings.is_subscription_revoke_enabled():
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('SUBSCRIPTION_REVOKE_BTN', '🔄 Перевыпустить подписку'),
|
||||
callback_data='subscription_revoke',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
@@ -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_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.",
|
||||
"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"
|
||||
}
|
||||
@@ -1779,5 +1779,15 @@
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>تورنت شناسایی شد</b>\n\nترافیک تورنت در اتصال{tariff_label} شما شناسایی شد. استفاده از تورنت ممکن است منجر به محدودیت اشتراک شود.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ بستن",
|
||||
"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": "🔗 اتصال"
|
||||
}
|
||||
@@ -1779,5 +1779,15 @@
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Обнаружен торрент</b>\n\nВ вашем подключении{tariff_label} обнаружен торрент-трафик. Использование торрентов может привести к ограничению подписки.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть",
|
||||
"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": "🔗 Подключиться"
|
||||
}
|
||||
@@ -1650,5 +1650,15 @@
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Виявлено торент</b>\n\nУ вашому підключенні{tariff_label} виявлено торент-трафік. Використання торентів може призвести до обмеження підписки.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрити",
|
||||
"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": "🔗 Підключитися"
|
||||
}
|
||||
@@ -1648,5 +1648,15 @@
|
||||
"BALANCE_TOPPED_UP_CART_SUFFICIENT": "✅ 余额已充值 {amount}!\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n余额足够完成订购。",
|
||||
"BALANCE_TOPPED_UP_CART_INSUFFICIENT": "✅ 余额已充值 {amount}!\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n还差:{missing}",
|
||||
"TRAFFIC_WARNING_ALERT": "⚠️ <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": "🔗 连接"
|
||||
}
|
||||
@@ -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()
|
||||
@@ -197,8 +197,9 @@ class AuraPayService:
|
||||
return False
|
||||
|
||||
# Сортируем ключи по алфавиту и конкатенируем значения
|
||||
# None → '' (PHP implode() converts null to empty string, not "None")
|
||||
sorted_keys = sorted(payload.keys())
|
||||
concatenated_values = ''.join(str(payload[key]) for key in sorted_keys)
|
||||
concatenated_values = ''.join(str(payload[key]) if payload[key] is not None else '' for key in sorted_keys)
|
||||
|
||||
expected = hmac.new(
|
||||
self.secret_key.encode('utf-8'),
|
||||
|
||||
@@ -31,6 +31,7 @@ from app.database.models import (
|
||||
AdminRole,
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
AppleTransaction,
|
||||
AuraPayPayment,
|
||||
BroadcastHistory,
|
||||
ButtonClickLog,
|
||||
@@ -204,6 +205,7 @@ class BackupService:
|
||||
RollyPayPayment,
|
||||
OverpayPayment,
|
||||
AuraPayPayment,
|
||||
AppleTransaction,
|
||||
SavedPaymentMethod,
|
||||
# --- Settings/content ---
|
||||
PaymentMethodConfig,
|
||||
@@ -1515,6 +1517,9 @@ class BackupService:
|
||||
'rollypay_payments',
|
||||
'overpay_payments',
|
||||
'aurapay_payments',
|
||||
'etoplatezhi_payments',
|
||||
'antilopay_payments',
|
||||
'apple_transactions',
|
||||
'saved_payment_methods',
|
||||
# --- Content/config ---
|
||||
'pinned_messages',
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -1044,7 +1044,7 @@ class MonitoringService:
|
||||
# Day 1 reminder
|
||||
if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2:
|
||||
if not await notification_sent(db, user.id, subscription.id, 'expired_1d'):
|
||||
success = await self._send_expired_day1_notification(user, subscription)
|
||||
success = await self._send_expired_day1_notification(db, user, subscription)
|
||||
if success:
|
||||
await record_notification(db, user.id, subscription.id, 'expired_1d')
|
||||
sent_day1 += 1
|
||||
@@ -1811,12 +1811,29 @@ class MonitoringService:
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool:
|
||||
async def _send_expired_day1_notification(self, db: AsyncSession, user: User, subscription: Subscription) -> bool:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
tariff = getattr(subscription, 'tariff', None)
|
||||
tariff_label = ''
|
||||
if settings.is_multi_tariff_enabled() and hasattr(subscription, 'tariff') and subscription.tariff:
|
||||
tariff_label = f' «{subscription.tariff.name}»'
|
||||
if settings.is_multi_tariff_enabled() and tariff:
|
||||
tariff_label = f' «{tariff.name}»'
|
||||
|
||||
renewal_period = (tariff.get_shortest_period() if tariff else None) or 30
|
||||
try:
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
pricing = await pricing_engine.calculate_renewal_price(db, subscription, renewal_period, user=user)
|
||||
renewal_price_kopeks = pricing.final_total
|
||||
except Exception as price_error:
|
||||
logger.warning(
|
||||
'Не удалось рассчитать цену продления для уведомления expired_1d, используем PRICE_30_DAYS',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
error=str(price_error),
|
||||
)
|
||||
renewal_price_kopeks = settings.PRICE_30_DAYS
|
||||
|
||||
template = texts.get(
|
||||
'SUBSCRIPTION_EXPIRED_1D',
|
||||
(
|
||||
@@ -1826,7 +1843,7 @@ class MonitoringService:
|
||||
)
|
||||
message = template.format(
|
||||
end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'),
|
||||
price=settings.format_price(settings.PRICE_30_DAYS),
|
||||
price=settings.format_price(renewal_price_kopeks),
|
||||
tariff_label=tariff_label,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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': 'СБП'},
|
||||
],
|
||||
},
|
||||
'etoplatezhi': {
|
||||
'default_display_name': settings.get_etoplatezhi_display_name(),
|
||||
'is_configured': settings.is_etoplatezhi_enabled(),
|
||||
'default_min': settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
'antilopay': {
|
||||
'default_display_name': settings.get_antilopay_display_name(),
|
||||
'is_configured': settings.is_antilopay_enabled(),
|
||||
'default_min': settings.ANTILOPAY_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.ANTILOPAY_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
{'id': 'sberpay', 'name': 'SberPay'},
|
||||
],
|
||||
},
|
||||
'jupiter': {
|
||||
'default_display_name': settings.get_jupiter_display_name(),
|
||||
'is_configured': settings.is_jupiter_enabled(),
|
||||
'default_min': settings.JUPITER_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.JUPITER_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
'donut': {
|
||||
'default_display_name': settings.get_donut_display_name(),
|
||||
'is_configured': settings.is_donut_enabled(),
|
||||
'default_min': settings.DONUT_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.DONUT_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
{'id': 'sbp_qr', 'name': 'СБП QR'},
|
||||
],
|
||||
},
|
||||
'lava': {
|
||||
'default_display_name': settings.get_lava_display_name(),
|
||||
'is_configured': settings.is_lava_enabled(),
|
||||
'default_min': settings.LAVA_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.LAVA_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +286,11 @@ DEFAULT_METHOD_ORDER = [
|
||||
'rollypay',
|
||||
'overpay',
|
||||
'aurapay',
|
||||
'etoplatezhi',
|
||||
'antilopay',
|
||||
'jupiter',
|
||||
'donut',
|
||||
'lava',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -15,17 +15,25 @@ from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.types import String as SAString
|
||||
|
||||
from app.database.models import (
|
||||
AntilopayPayment,
|
||||
AuraPayPayment,
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
DonutPayment,
|
||||
EtoplatezhiPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
JupiterPayment,
|
||||
KassaAiPayment,
|
||||
LavaPayment,
|
||||
MulenPayPayment,
|
||||
OverpayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethod,
|
||||
PayPearPayment,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
RollyPayPayment,
|
||||
SeverPayPayment,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
@@ -683,6 +691,280 @@ async def _search_overpay(db: AsyncSession, params: SearchParams) -> list[Pendin
|
||||
return records
|
||||
|
||||
|
||||
async def _search_paypear(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(PayPearPayment).options(selectinload(PayPearPayment.user)).order_by(desc(PayPearPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, PayPearPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
PayPearPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
PayPearPayment.paypear_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, PayPearPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.PAYPEAR,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_rollypay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(RollyPayPayment).options(selectinload(RollyPayPayment.user)).order_by(desc(RollyPayPayment.created_at))
|
||||
)
|
||||
stmt = _apply_date_filter(stmt, RollyPayPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
RollyPayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
RollyPayPayment.rollypay_payment_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, RollyPayPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.ROLLYPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_aurapay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(AuraPayPayment).options(selectinload(AuraPayPayment.user)).order_by(desc(AuraPayPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, AuraPayPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
AuraPayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
AuraPayPayment.aurapay_invoice_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, AuraPayPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.AURAPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_etoplatezhi(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(EtoplatezhiPayment)
|
||||
.options(selectinload(EtoplatezhiPayment.user))
|
||||
.order_by(desc(EtoplatezhiPayment.created_at))
|
||||
)
|
||||
stmt = _apply_date_filter(stmt, EtoplatezhiPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
EtoplatezhiPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
EtoplatezhiPayment.etoplatezhi_payment_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, EtoplatezhiPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.ETOPLATEZHI,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_antilopay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(AntilopayPayment)
|
||||
.options(selectinload(AntilopayPayment.user))
|
||||
.order_by(desc(AntilopayPayment.created_at))
|
||||
)
|
||||
stmt = _apply_date_filter(stmt, AntilopayPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
AntilopayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
AntilopayPayment.antilopay_payment_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, AntilopayPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.ANTILOPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_jupiter(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(JupiterPayment).options(selectinload(JupiterPayment.user)).order_by(desc(JupiterPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, JupiterPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
JupiterPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
JupiterPayment.jupiter_transaction_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, JupiterPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.JUPITER,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_donut(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(DonutPayment).options(selectinload(DonutPayment.user)).order_by(desc(DonutPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, DonutPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
DonutPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
DonutPayment.donut_transaction_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, DonutPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.DONUT,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_lava(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(LavaPayment).options(selectinload(LavaPayment.user)).order_by(desc(LavaPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, LavaPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
LavaPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
LavaPayment.lava_invoice_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, LavaPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.LAVA,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_stars(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
@@ -737,6 +1019,14 @@ _PROVIDER_SEARCH_MAP: dict[PaymentMethod, Any] = {
|
||||
PaymentMethod.RIOPAY: _search_riopay,
|
||||
PaymentMethod.SEVERPAY: _search_severpay,
|
||||
PaymentMethod.OVERPAY: _search_overpay,
|
||||
PaymentMethod.PAYPEAR: _search_paypear,
|
||||
PaymentMethod.ROLLYPAY: _search_rollypay,
|
||||
PaymentMethod.AURAPAY: _search_aurapay,
|
||||
PaymentMethod.ETOPLATEZHI: _search_etoplatezhi,
|
||||
PaymentMethod.ANTILOPAY: _search_antilopay,
|
||||
PaymentMethod.JUPITER: _search_jupiter,
|
||||
PaymentMethod.DONUT: _search_donut,
|
||||
PaymentMethod.LAVA: _search_lava,
|
||||
PaymentMethod.TELEGRAM_STARS: _search_stars,
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,15 @@ from app.services.payment import (
|
||||
WataPaymentMixin,
|
||||
YooKassaPaymentMixin,
|
||||
)
|
||||
from app.services.payment.antilopay import AntilopayPaymentMixin
|
||||
from app.services.payment.aurapay import AuraPayPaymentMixin
|
||||
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
|
||||
from app.services.payment.donut import DonutPaymentMixin
|
||||
from app.services.payment.etoplatezhi import EtoplatezhiPaymentMixin
|
||||
from app.services.payment.freekassa import FreekassaPaymentMixin
|
||||
from app.services.payment.jupiter import JupiterPaymentMixin
|
||||
from app.services.payment.kassa_ai import KassaAiPaymentMixin
|
||||
from app.services.payment.lava import LavaPaymentMixin
|
||||
from app.services.payment.overpay import OverpayPaymentMixin
|
||||
from app.services.payment.paypear import PayPearPaymentMixin
|
||||
from app.services.payment.riopay import RioPayPaymentMixin
|
||||
@@ -482,6 +487,181 @@ async def link_aurapay_payment_to_transaction(*args, **kwargs):
|
||||
return await aurapay_crud.link_aurapay_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_etoplatezhi_payment(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.create_etoplatezhi_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_order_id(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.get_etoplatezhi_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_invoice_id(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.get_etoplatezhi_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_id(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.get_etoplatezhi_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_id_for_update(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.get_etoplatezhi_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_etoplatezhi_payment_status(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.update_etoplatezhi_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_etoplatezhi_payment_to_transaction(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.link_etoplatezhi_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_antilopay_payment(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.create_antilopay_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_order_id(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.get_antilopay_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_invoice_id(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.get_antilopay_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_id(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.get_antilopay_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_id_for_update(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.get_antilopay_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_antilopay_payment_status(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.update_antilopay_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_antilopay_payment_to_transaction(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.link_antilopay_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_jupiter_payment(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.create_jupiter_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_order_id(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.get_jupiter_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_invoice_id(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.get_jupiter_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_id(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.get_jupiter_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_id_for_update(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.get_jupiter_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_jupiter_payment_status(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.update_jupiter_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_jupiter_payment_to_transaction(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.link_jupiter_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_donut_payment(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.create_donut_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_donut_payment_by_order_id(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.get_donut_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_donut_payment_by_invoice_id(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.get_donut_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_donut_payment_by_id(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.get_donut_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_donut_payment_by_id_for_update(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.get_donut_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_donut_payment_status(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.update_donut_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_donut_payment_to_transaction(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.link_donut_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_lava_payment(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.create_lava_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_lava_payment_by_order_id(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.get_lava_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_lava_payment_by_invoice_id(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.get_lava_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_lava_payment_by_id(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.get_lava_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_lava_payment_by_id_for_update(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.get_lava_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_lava_payment_status(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.update_lava_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_lava_payment_to_transaction(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.link_lava_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
# Mapping from model_name to getter function name for providers
|
||||
# where it differs from the standard get_{model_name}_payment_by_id pattern.
|
||||
_GETTER_OVERRIDES: dict[str, str] = {
|
||||
@@ -509,6 +689,11 @@ class PaymentService(
|
||||
RollyPayPaymentMixin,
|
||||
OverpayPaymentMixin,
|
||||
AuraPayPaymentMixin,
|
||||
EtoplatezhiPaymentMixin,
|
||||
AntilopayPaymentMixin,
|
||||
JupiterPaymentMixin,
|
||||
DonutPaymentMixin,
|
||||
LavaPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
|
||||
@@ -1016,6 +1201,116 @@ class PaymentService(
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Etoplatezhi ------------------------------------------------------
|
||||
if payment_method == 'etoplatezhi':
|
||||
if not settings.is_etoplatezhi_enabled():
|
||||
logger.warning('Etoplatezhi is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_etoplatezhi_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'etoplatezhi')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'etoplatezhi',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Antilopay --------------------------------------------------------
|
||||
if payment_method == 'antilopay':
|
||||
if not settings.is_antilopay_enabled():
|
||||
logger.warning('Antilopay is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_antilopay_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'antilopay')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'antilopay',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Jupiter ----------------------------------------------------------
|
||||
if payment_method == 'jupiter':
|
||||
if not settings.is_jupiter_enabled():
|
||||
logger.warning('Jupiter is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_jupiter_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'jupiter')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'jupiter',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Donut ------------------------------------------------------------
|
||||
if payment_method == 'donut':
|
||||
if not settings.is_donut_enabled():
|
||||
logger.warning('Donut is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_donut_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'donut')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'donut',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Lava -------------------------------------------------------------
|
||||
if payment_method == 'lava':
|
||||
if not settings.is_lava_enabled():
|
||||
logger.warning('Lava is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_lava_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'lava')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'lava',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Telegram Stars ---------------------------------------------------
|
||||
if payment_method == 'telegram_stars':
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
|
||||
@@ -18,16 +18,24 @@ from sqlalchemy.orm import selectinload
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import (
|
||||
AntilopayPayment,
|
||||
AuraPayPayment,
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
DonutPayment,
|
||||
EtoplatezhiPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
JupiterPayment,
|
||||
KassaAiPayment,
|
||||
LavaPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethod,
|
||||
PayPearPayment,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
RollyPayPayment,
|
||||
SeverPayPayment,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
@@ -77,6 +85,11 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.RIOPAY,
|
||||
PaymentMethod.SEVERPAY,
|
||||
PaymentMethod.OVERPAY,
|
||||
PaymentMethod.PAYPEAR,
|
||||
PaymentMethod.ROLLYPAY,
|
||||
PaymentMethod.AURAPAY,
|
||||
# ETOPLATEZHI / ANTILOPAY / JUPITER / DONUT / LAVA — webhook-driven,
|
||||
# без API-метода синхронизации БД, manual check не реализован.
|
||||
}
|
||||
)
|
||||
|
||||
@@ -98,6 +111,9 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.RIOPAY,
|
||||
PaymentMethod.SEVERPAY,
|
||||
PaymentMethod.OVERPAY,
|
||||
PaymentMethod.PAYPEAR,
|
||||
PaymentMethod.ROLLYPAY,
|
||||
PaymentMethod.AURAPAY,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -129,6 +145,22 @@ def method_display_name(method: PaymentMethod) -> str:
|
||||
return settings.get_severpay_display_name()
|
||||
if method == PaymentMethod.OVERPAY:
|
||||
return settings.get_overpay_display_name()
|
||||
if method == PaymentMethod.PAYPEAR:
|
||||
return settings.get_paypear_display_name()
|
||||
if method == PaymentMethod.ROLLYPAY:
|
||||
return settings.get_rollypay_display_name()
|
||||
if method == PaymentMethod.AURAPAY:
|
||||
return settings.get_aurapay_display_name()
|
||||
if method == PaymentMethod.ETOPLATEZHI:
|
||||
return settings.get_etoplatezhi_display_name()
|
||||
if method == PaymentMethod.ANTILOPAY:
|
||||
return settings.get_antilopay_display_name()
|
||||
if method == PaymentMethod.JUPITER:
|
||||
return settings.get_jupiter_display_name()
|
||||
if method == PaymentMethod.DONUT:
|
||||
return settings.get_donut_display_name()
|
||||
if method == PaymentMethod.LAVA:
|
||||
return settings.get_lava_display_name()
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return 'Telegram Stars'
|
||||
return method.value
|
||||
@@ -161,6 +193,22 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
|
||||
return settings.is_severpay_enabled()
|
||||
if method == PaymentMethod.OVERPAY:
|
||||
return settings.is_overpay_enabled()
|
||||
if method == PaymentMethod.PAYPEAR:
|
||||
return settings.is_paypear_enabled()
|
||||
if method == PaymentMethod.ROLLYPAY:
|
||||
return settings.is_rollypay_enabled()
|
||||
if method == PaymentMethod.AURAPAY:
|
||||
return settings.is_aurapay_enabled()
|
||||
if method == PaymentMethod.ETOPLATEZHI:
|
||||
return settings.is_etoplatezhi_enabled()
|
||||
if method == PaymentMethod.ANTILOPAY:
|
||||
return settings.is_antilopay_enabled()
|
||||
if method == PaymentMethod.JUPITER:
|
||||
return settings.is_jupiter_enabled()
|
||||
if method == PaymentMethod.DONUT:
|
||||
return settings.is_donut_enabled()
|
||||
if method == PaymentMethod.LAVA:
|
||||
return settings.is_lava_enabled()
|
||||
return False
|
||||
|
||||
|
||||
@@ -410,6 +458,62 @@ def _is_riopay_pending(payment: RioPayPayment) -> bool:
|
||||
return status in {'pending'}
|
||||
|
||||
|
||||
def _is_paypear_pending(payment: PayPearPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_rollypay_pending(payment: RollyPayPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_aurapay_pending(payment: AuraPayPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_etoplatezhi_pending(payment: EtoplatezhiPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_antilopay_pending(payment: AntilopayPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_jupiter_pending(payment: JupiterPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_donut_pending(payment: DonutPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_lava_pending(payment: LavaPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
|
||||
payload = payment.payload or ''
|
||||
match = re.search(r'_(\d+)$', payload)
|
||||
@@ -779,6 +883,214 @@ async def _fetch_severpay_payments(db: AsyncSession, cutoff: datetime) -> list[P
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_paypear_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(PayPearPayment)
|
||||
.options(selectinload(PayPearPayment.user))
|
||||
.where(PayPearPayment.created_at >= cutoff)
|
||||
.order_by(desc(PayPearPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_paypear_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.PAYPEAR,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_rollypay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(RollyPayPayment)
|
||||
.options(selectinload(RollyPayPayment.user))
|
||||
.where(RollyPayPayment.created_at >= cutoff)
|
||||
.order_by(desc(RollyPayPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_rollypay_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.ROLLYPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_aurapay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(AuraPayPayment)
|
||||
.options(selectinload(AuraPayPayment.user))
|
||||
.where(AuraPayPayment.created_at >= cutoff)
|
||||
.order_by(desc(AuraPayPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_aurapay_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.AURAPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_etoplatezhi_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(EtoplatezhiPayment)
|
||||
.options(selectinload(EtoplatezhiPayment.user))
|
||||
.where(EtoplatezhiPayment.created_at >= cutoff)
|
||||
.order_by(desc(EtoplatezhiPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_etoplatezhi_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.ETOPLATEZHI,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_antilopay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(AntilopayPayment)
|
||||
.options(selectinload(AntilopayPayment.user))
|
||||
.where(AntilopayPayment.created_at >= cutoff)
|
||||
.order_by(desc(AntilopayPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_antilopay_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.ANTILOPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_jupiter_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(JupiterPayment)
|
||||
.options(selectinload(JupiterPayment.user))
|
||||
.where(JupiterPayment.created_at >= cutoff)
|
||||
.order_by(desc(JupiterPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_jupiter_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.JUPITER,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_donut_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(DonutPayment)
|
||||
.options(selectinload(DonutPayment.user))
|
||||
.where(DonutPayment.created_at >= cutoff)
|
||||
.order_by(desc(DonutPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_donut_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.DONUT,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_lava_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(LavaPayment)
|
||||
.options(selectinload(LavaPayment.user))
|
||||
.where(LavaPayment.created_at >= cutoff)
|
||||
.order_by(desc(LavaPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_lava_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.LAVA,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
@@ -828,6 +1140,14 @@ async def list_recent_pending_payments(
|
||||
await _fetch_kassa_ai_payments(db, cutoff),
|
||||
await _fetch_riopay_payments(db, cutoff),
|
||||
await _fetch_severpay_payments(db, cutoff),
|
||||
await _fetch_paypear_payments(db, cutoff),
|
||||
await _fetch_rollypay_payments(db, cutoff),
|
||||
await _fetch_aurapay_payments(db, cutoff),
|
||||
await _fetch_etoplatezhi_payments(db, cutoff),
|
||||
await _fetch_antilopay_payments(db, cutoff),
|
||||
await _fetch_jupiter_payments(db, cutoff),
|
||||
await _fetch_donut_payments(db, cutoff),
|
||||
await _fetch_lava_payments(db, cutoff),
|
||||
await _fetch_stars_transactions(db, cutoff),
|
||||
)
|
||||
|
||||
@@ -1026,6 +1346,126 @@ async def get_payment_record(
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.PAYPEAR:
|
||||
payment = await db.get(PayPearPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.ROLLYPAY:
|
||||
payment = await db.get(RollyPayPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.AURAPAY:
|
||||
payment = await db.get(AuraPayPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.ETOPLATEZHI:
|
||||
payment = await db.get(EtoplatezhiPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.ANTILOPAY:
|
||||
payment = await db.get(AntilopayPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.JUPITER:
|
||||
payment = await db.get(JupiterPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.DONUT:
|
||||
payment = await db.get(DonutPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.LAVA:
|
||||
payment = await db.get(LavaPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
transaction = await db.get(Transaction, local_payment_id)
|
||||
if not transaction:
|
||||
@@ -1098,6 +1538,27 @@ async def run_manual_check(
|
||||
payment = result.get('payment') if result else None
|
||||
else:
|
||||
payment = None
|
||||
elif method == PaymentMethod.PAYPEAR:
|
||||
paypear_payment = await db.get(PayPearPayment, local_payment_id)
|
||||
if paypear_payment:
|
||||
result = await payment_service.check_paypear_payment_status(db, paypear_payment.order_id)
|
||||
payment = result.get('payment') if result else None
|
||||
else:
|
||||
payment = None
|
||||
elif method == PaymentMethod.ROLLYPAY:
|
||||
rollypay_payment = await db.get(RollyPayPayment, local_payment_id)
|
||||
if rollypay_payment:
|
||||
result = await payment_service.check_rollypay_payment_status(db, rollypay_payment.order_id)
|
||||
payment = result.get('payment') if result else None
|
||||
else:
|
||||
payment = None
|
||||
elif method == PaymentMethod.AURAPAY:
|
||||
aurapay_payment = await db.get(AuraPayPayment, local_payment_id)
|
||||
if aurapay_payment:
|
||||
result = await payment_service.check_aurapay_payment_status(db, aurapay_payment.order_id)
|
||||
payment = result.get('payment') if result else None
|
||||
else:
|
||||
payment = None
|
||||
else:
|
||||
logger.warning('Manual check requested for unsupported method', method=method)
|
||||
return None
|
||||
|
||||
@@ -99,6 +99,73 @@ async def clear_pending_referral(telegram_id: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pending campaign helpers (Redis)
|
||||
#
|
||||
# Mirrors pending_referral: lets us survive the case where /start <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:
|
||||
"""Проверяет, исчерпан ли лимит комиссионных платежей для пары реферер-реферал."""
|
||||
if settings.REFERRAL_MAX_COMMISSION_PAYMENTS <= 0:
|
||||
|
||||
@@ -95,6 +95,11 @@ class BotConfigurationService:
|
||||
'ROLLYPAY': '💳 RollyPay',
|
||||
'OVERPAY': '💳 Overpay',
|
||||
'AURAPAY': '💳 AuraPay',
|
||||
'ANTILOPAY': '🦌 Antilopay',
|
||||
'ETOPLATEZHI': '💳 Etoplatezhi',
|
||||
'JUPITER': '🪐 Jupiter',
|
||||
'DONUT': '🍩 Donut',
|
||||
'LAVA': '🌋 Lava',
|
||||
'YOOKASSA': '🟣 YooKassa',
|
||||
'PLATEGA': '💳 {platega_name}',
|
||||
'TRIBUTE': '🎁 Tribute',
|
||||
@@ -159,6 +164,11 @@ class BotConfigurationService:
|
||||
'ROLLYPAY': 'RollyPay: платёжный шлюз rollypay.io с СБП, картами и криптовалютой.',
|
||||
'OVERPAY': 'Overpay: платёжный шлюз pay.overpay.io с mTLS и поддержкой карт и СБП.',
|
||||
'AURAPAY': 'AuraPay: платёжный шлюз aurapay.tech с поддержкой карт и СБП.',
|
||||
'ANTILOPAY': 'Antilopay: lk.antilopay.com, оплата картой, СБП и SberPay.',
|
||||
'ETOPLATEZHI': 'Etoplatezhi: paymentpage.etoplatezhi.ru, оплата картой и через СБП.',
|
||||
'JUPITER': 'Jupiter (FPGate P2P v2.1): app.juppiter.tech, эквайринг СБП с HMAC-SHA256.',
|
||||
'DONUT': 'Donut P2P: gw.donut.business, P2P-оплата картой, СБП по телефону и QR.',
|
||||
'LAVA': 'Lava Business: gate.lava.ru, оплата картой и СБП с HMAC-SHA256 и подтверждением через webhook.',
|
||||
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
|
||||
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
|
||||
'PAL24': 'PAL24 / PayPalych подключения и лимиты.',
|
||||
@@ -373,6 +383,11 @@ class BotConfigurationService:
|
||||
'ROLLYPAY_': 'ROLLYPAY',
|
||||
'OVERPAY_': 'OVERPAY',
|
||||
'AURAPAY_': 'AURAPAY',
|
||||
'ANTILOPAY_': 'ANTILOPAY',
|
||||
'ETOPLATEZHI_': 'ETOPLATEZHI',
|
||||
'JUPITER_': 'JUPITER',
|
||||
'DONUT_': 'DONUT',
|
||||
'LAVA_': 'LAVA',
|
||||
'PLATEGA_': 'PLATEGA',
|
||||
'MULENPAY_': 'MULENPAY',
|
||||
'PAL24_': 'PAL24',
|
||||
|
||||
+153
-1
@@ -232,7 +232,35 @@ def get_available_payment_methods() -> list[dict[str, str]]:
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_aurapay_enabled():
|
||||
if settings.is_aurapay_sbp_enabled():
|
||||
sbp_name = settings.get_aurapay_sbp_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'aurapay_sbp',
|
||||
'name': sbp_name,
|
||||
'icon': '📱',
|
||||
'description': f'через {sbp_name}',
|
||||
'callback': 'topup_aurapay_sbp',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_aurapay_card_enabled():
|
||||
card_name = settings.get_aurapay_card_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'aurapay_card',
|
||||
'name': card_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {card_name}',
|
||||
'callback': 'topup_aurapay_card',
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
settings.is_aurapay_enabled()
|
||||
and not settings.is_aurapay_sbp_enabled()
|
||||
and not settings.is_aurapay_card_enabled()
|
||||
):
|
||||
aurapay_name = settings.get_aurapay_display_name()
|
||||
methods.append(
|
||||
{
|
||||
@@ -244,6 +272,99 @@ def get_available_payment_methods() -> list[dict[str, str]]:
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_etoplatezhi_sbp_enabled():
|
||||
sbp_name = settings.get_etoplatezhi_sbp_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'etoplatezhi_sbp',
|
||||
'name': sbp_name,
|
||||
'icon': '📱',
|
||||
'description': f'через {sbp_name}',
|
||||
'callback': 'topup_etoplatezhi_sbp',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_etoplatezhi_card_enabled():
|
||||
card_name = settings.get_etoplatezhi_card_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'etoplatezhi_card',
|
||||
'name': card_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {card_name}',
|
||||
'callback': 'topup_etoplatezhi_card',
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
settings.is_etoplatezhi_enabled()
|
||||
and not settings.is_etoplatezhi_sbp_enabled()
|
||||
and not settings.is_etoplatezhi_card_enabled()
|
||||
):
|
||||
etoplatezhi_name = settings.get_etoplatezhi_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'etoplatezhi',
|
||||
'name': etoplatezhi_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {etoplatezhi_name}',
|
||||
'callback': 'topup_etoplatezhi',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_antilopay_sbp_enabled():
|
||||
sbp_name = settings.get_antilopay_sbp_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'antilopay_sbp',
|
||||
'name': sbp_name,
|
||||
'icon': '📱',
|
||||
'description': f'через {sbp_name}',
|
||||
'callback': 'topup_antilopay_sbp',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_antilopay_card_enabled():
|
||||
card_name = settings.get_antilopay_card_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'antilopay_card',
|
||||
'name': card_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {card_name}',
|
||||
'callback': 'topup_antilopay_card',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_antilopay_sberpay_enabled():
|
||||
sberpay_name = settings.get_antilopay_sberpay_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'antilopay_sberpay',
|
||||
'name': sberpay_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {sberpay_name}',
|
||||
'callback': 'topup_antilopay_sberpay',
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
settings.is_antilopay_enabled()
|
||||
and not settings.is_antilopay_sbp_enabled()
|
||||
and not settings.is_antilopay_card_enabled()
|
||||
and not settings.is_antilopay_sberpay_enabled()
|
||||
):
|
||||
antilopay_name = settings.get_antilopay_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'antilopay',
|
||||
'name': antilopay_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {antilopay_name}',
|
||||
'callback': 'topup_antilopay',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_support_topup_enabled():
|
||||
methods.append(
|
||||
{
|
||||
@@ -381,6 +502,24 @@ def is_payment_method_available(method_id: str) -> bool:
|
||||
return settings.is_overpay_enabled()
|
||||
if method_id == 'aurapay':
|
||||
return settings.is_aurapay_enabled()
|
||||
if method_id == 'aurapay_sbp':
|
||||
return settings.is_aurapay_sbp_enabled()
|
||||
if method_id == 'aurapay_card':
|
||||
return settings.is_aurapay_card_enabled()
|
||||
if method_id == 'etoplatezhi':
|
||||
return settings.is_etoplatezhi_enabled()
|
||||
if method_id == 'etoplatezhi_sbp':
|
||||
return settings.is_etoplatezhi_sbp_enabled()
|
||||
if method_id == 'etoplatezhi_card':
|
||||
return settings.is_etoplatezhi_card_enabled()
|
||||
if method_id == 'antilopay':
|
||||
return settings.is_antilopay_enabled()
|
||||
if method_id == 'antilopay_sbp':
|
||||
return settings.is_antilopay_sbp_enabled()
|
||||
if method_id == 'antilopay_card':
|
||||
return settings.is_antilopay_card_enabled()
|
||||
if method_id == 'antilopay_sberpay':
|
||||
return settings.is_antilopay_sberpay_enabled()
|
||||
if method_id == 'support':
|
||||
return settings.is_support_topup_enabled()
|
||||
return False
|
||||
@@ -409,6 +548,15 @@ def get_payment_method_status() -> dict[str, bool]:
|
||||
'rollypay': settings.is_rollypay_enabled(),
|
||||
'overpay': settings.is_overpay_enabled(),
|
||||
'aurapay': settings.is_aurapay_enabled(),
|
||||
'aurapay_sbp': settings.is_aurapay_sbp_enabled(),
|
||||
'aurapay_card': settings.is_aurapay_card_enabled(),
|
||||
'etoplatezhi': settings.is_etoplatezhi_enabled(),
|
||||
'etoplatezhi_sbp': settings.is_etoplatezhi_sbp_enabled(),
|
||||
'etoplatezhi_card': settings.is_etoplatezhi_card_enabled(),
|
||||
'antilopay': settings.is_antilopay_enabled(),
|
||||
'antilopay_sbp': settings.is_antilopay_sbp_enabled(),
|
||||
'antilopay_card': settings.is_antilopay_card_enabled(),
|
||||
'antilopay_sberpay': settings.is_antilopay_sberpay_enabled(),
|
||||
'support': settings.is_support_topup_enabled(),
|
||||
}
|
||||
|
||||
@@ -454,4 +602,8 @@ def get_enabled_payment_methods_count() -> int:
|
||||
count += 1
|
||||
if settings.is_aurapay_enabled():
|
||||
count += 1
|
||||
if settings.is_etoplatezhi_enabled():
|
||||
count += 1
|
||||
if settings.is_antilopay_enabled():
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@@ -1469,6 +1469,241 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Etoplatezhi webhook
|
||||
if settings.is_etoplatezhi_enabled():
|
||||
|
||||
@router.get(settings.ETOPLATEZHI_WEBHOOK_PATH)
|
||||
async def etoplatezhi_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'etoplatezhi_webhook',
|
||||
'enabled': settings.is_etoplatezhi_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.ETOPLATEZHI_WEBHOOK_PATH)
|
||||
async def etoplatezhi_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Etoplatezhi webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Подпись внутри JSON body (поле signature)
|
||||
from app.services.etoplatezhi_service import etoplatezhi_service
|
||||
|
||||
if not etoplatezhi_service.verify_callback_signature(payload):
|
||||
logger.warning('Etoplatezhi webhook: invalid signature')
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_etoplatezhi_callback',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Etoplatezhi webhook processing failed',
|
||||
data=payload.get('payment', {}).get('id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Etoplatezhi webhook processing error', error=e)
|
||||
# Always return 200 — Etoplatezhi expects 200 for valid signature
|
||||
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Antilopay webhook
|
||||
if settings.is_antilopay_enabled():
|
||||
|
||||
@router.get(settings.ANTILOPAY_WEBHOOK_PATH)
|
||||
async def antilopay_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'antilopay_webhook',
|
||||
'enabled': settings.is_antilopay_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.ANTILOPAY_WEBHOOK_PATH)
|
||||
async def antilopay_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Antilopay webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Подпись в заголовке X-Apay-Callback, верифицируется публичным ключом
|
||||
from app.services.antilopay_service import antilopay_service
|
||||
|
||||
callback_signature = request.headers.get('X-Apay-Callback') or ''
|
||||
if not antilopay_service.verify_callback_signature(raw_body, callback_signature):
|
||||
logger.warning('Antilopay webhook: invalid signature')
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_antilopay_callback',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Antilopay webhook processing failed',
|
||||
data=payload.get('payment_id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Antilopay webhook processing error', error=e)
|
||||
# Always return 200 — Antilopay retries every 3min for 1hr on non-200
|
||||
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Jupiter webhook (FPGate P2P v2.1)
|
||||
if settings.is_jupiter_enabled():
|
||||
|
||||
@router.get(settings.JUPITER_WEBHOOK_PATH)
|
||||
async def jupiter_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'jupiter_webhook',
|
||||
'enabled': settings.is_jupiter_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.JUPITER_WEBHOOK_PATH)
|
||||
async def jupiter_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Jupiter webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from app.services.jupiter_service import jupiter_service
|
||||
|
||||
if not jupiter_service.verify_callback_signature(payload):
|
||||
logger.warning('Jupiter webhook: invalid signature')
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_jupiter_callback',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Jupiter webhook processing failed',
|
||||
transaction_id=payload.get('transaction_id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Jupiter webhook processing error', error=e)
|
||||
# FPGate ожидает HTTP 200 как подтверждение приёма callback
|
||||
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Lava webhook (Lava Business)
|
||||
if settings.is_lava_enabled():
|
||||
|
||||
@router.get(settings.LAVA_WEBHOOK_PATH)
|
||||
async def lava_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'lava_webhook',
|
||||
'enabled': settings.is_lava_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.LAVA_WEBHOOK_PATH)
|
||||
async def lava_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Lava webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from app.services.lava_service import lava_service
|
||||
|
||||
received_signature = (request.headers.get('Authorization') or '').strip()
|
||||
if not lava_service.verify_webhook_signature(raw_body, received_signature):
|
||||
logger.warning('Lava webhook: invalid signature')
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_lava_callback',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Lava webhook processing failed',
|
||||
order_id=payload.get('order_id'),
|
||||
invoice_id=payload.get('invoice_id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Lava webhook processing error', error=e)
|
||||
# Lava ожидает HTTP 200 как подтверждение приёма; иначе будет повтор до 5 раз раз в 150с
|
||||
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Donut webhook (Donut P2P)
|
||||
if settings.is_donut_enabled():
|
||||
|
||||
@router.get(settings.DONUT_WEBHOOK_PATH)
|
||||
async def donut_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'donut_webhook',
|
||||
'enabled': settings.is_donut_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.DONUT_WEBHOOK_PATH)
|
||||
async def donut_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Donut webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from app.services.donut_service import donut_service
|
||||
|
||||
if not donut_service.verify_callback_signature(payload):
|
||||
logger.warning('Donut webhook: invalid signature')
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_donut_callback',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Donut webhook processing failed',
|
||||
transaction_id=payload.get('transaction_id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Donut webhook processing error', error=e)
|
||||
# Donut ожидает HTTP 200 как подтверждение приёма callback
|
||||
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
if routes_registered:
|
||||
|
||||
@router.get('/health/payment-webhooks')
|
||||
@@ -1493,6 +1728,11 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
'rollypay_enabled': settings.is_rollypay_enabled(),
|
||||
'overpay_enabled': settings.is_overpay_enabled(),
|
||||
'aurapay_enabled': settings.is_aurapay_enabled(),
|
||||
'etoplatezhi_enabled': settings.is_etoplatezhi_enabled(),
|
||||
'antilopay_enabled': settings.is_antilopay_enabled(),
|
||||
'jupiter_enabled': settings.is_jupiter_enabled(),
|
||||
'donut_enabled': settings.is_donut_enabled(),
|
||||
'lava_enabled': settings.is_lava_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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')
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.53.0"
|
||||
version = "3.54.0"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
@@ -26,6 +26,7 @@ dependencies = [
|
||||
'rich>=14.0',
|
||||
'aiohttp-socks>=0.10.1',
|
||||
'httpx[socks]>=0.27.0',
|
||||
'pycryptodome>=3.21.0',
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
Vendored
+549
@@ -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
|
||||
@@ -883,6 +883,36 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodome"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodomex"
|
||||
version = "3.23.0"
|
||||
@@ -1155,6 +1185,7 @@ dependencies = [
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "httpx", extra = ["socks"] },
|
||||
{ name = "packaging" },
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pyyaml" },
|
||||
@@ -1187,6 +1218,7 @@ requires-dist = [
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.129.0" },
|
||||
{ name = "httpx", extras = ["socks"], specifier = ">=0.27.0" },
|
||||
{ name = "packaging", specifier = ">=26.0" },
|
||||
{ name = "pycryptodome", specifier = ">=3.21.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.11.0" },
|
||||
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||
|
||||
Reference in New Issue
Block a user