From f321ded9c0033ff3848a92aef44cbfef7f8d3883 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 19:36:22 +0300 Subject: [PATCH] feat: integrate Jupiter (FPGate P2P) and Donut payment providers - Jupiter: SBP via app.juppiter.tech (FPGate P2P v2.1) - Donut: CARD/SBP/SBP_QR via gw.donut.business (Donut P2P) - HMAC-SHA256 signing verified against spec reference vectors - Sticky terminal-status guard in callback (amount_mismatch/declined/cancelled cannot be re-credited by replayed webhook) - Mirrors existing Antilopay/Etoplatezhi mixin pattern: service, mixin, CRUD, Alembic migration, handlers, keyboards, webhook, cabinet route, status mapping - Adds JUPITER and DONUT settings categories with title/description/prefix - Backfills missing ANTILOPAY and ETOPLATEZHI category metadata --- app/cabinet/routes/balance.py | 86 +++ app/config.py | 109 ++++ app/database/crud/donut.py | 159 ++++++ app/database/crud/jupiter.py | 159 ++++++ app/database/models.py | 126 +++++ app/handlers/balance/donut.py | 305 +++++++++++ app/handlers/balance/jupiter.py | 273 ++++++++++ app/handlers/balance/main.py | 31 ++ app/keyboards/inline.py | 77 +++ app/services/donut_service.py | 266 +++++++++ app/services/jupiter_service.py | 268 ++++++++++ app/services/payment/donut.py | 506 ++++++++++++++++++ app/services/payment/jupiter.py | 498 +++++++++++++++++ app/services/payment_method_config_service.py | 22 + app/services/payment_service.py | 118 ++++ app/services/system_settings_service.py | 12 + app/webserver/payments.py | 94 ++++ .../versions/0072_create_jupiter_payments.py | 45 ++ .../versions/0073_create_donut_payments.py | 45 ++ 19 files changed, 3199 insertions(+) create mode 100644 app/database/crud/donut.py create mode 100644 app/database/crud/jupiter.py create mode 100644 app/handlers/balance/donut.py create mode 100644 app/handlers/balance/jupiter.py create mode 100644 app/services/donut_service.py create mode 100644 app/services/jupiter_service.py create mode 100644 app/services/payment/donut.py create mode 100644 app/services/payment/jupiter.py create mode 100644 migrations/alembic/versions/0072_create_jupiter_payments.py create mode 100644 migrations/alembic/versions/0073_create_donut_payments.py diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index c437ab7a..f70f2c20 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -914,6 +914,68 @@ async def create_topup( detail='Failed to create AuraPay payment', ) + elif request.payment_method == 'jupiter': + if not settings.is_jupiter_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Jupiter payment method is unavailable', + ) + + payment_service = PaymentService() + payment_method_type = request.payment_option or None + result = await payment_service.create_jupiter_payment( + db=db, + user_id=user.id, + amount_kopeks=request.amount_kopeks, + description=settings.get_balance_payment_description( + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), + email=getattr(user, 'email', None), + language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, + payment_method_type=payment_method_type, + return_url=cabinet_success_url, + ) + + if result and result.get('payment_url'): + payment_url = result.get('payment_url') + payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending') + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to create Jupiter payment', + ) + + elif request.payment_method == 'donut': + if not settings.is_donut_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Donut payment method is unavailable', + ) + + payment_service = PaymentService() + payment_method_type = request.payment_option or None + result = await payment_service.create_donut_payment( + db=db, + user_id=user.id, + amount_kopeks=request.amount_kopeks, + description=settings.get_balance_payment_description( + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), + email=getattr(user, 'email', None), + language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, + payment_method_type=payment_method_type, + return_url=cabinet_success_url, + ) + + if result and result.get('payment_url'): + payment_url = result.get('payment_url') + payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending') + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to create Donut payment', + ) + else: # For other payment methods, redirect to bot raise HTTPException( @@ -1065,6 +1127,30 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]: } return mapping.get(status, ('❓', 'Неизвестно')) + if record.method == PaymentMethod.JUPITER: + mapping = { + 'pending': ('⏳', 'Ожидает оплаты'), + 'processing': ('⌛', 'Обрабатывается'), + 'success': ('✅', 'Оплачено'), + 'cancelled': ('❌', 'Отменено'), + 'declined': ('❌', 'Отклонено'), + 'error': ('❌', 'Ошибка'), + 'amount_mismatch': ('⚠️', 'Несовпадение суммы'), + } + return mapping.get(status, ('❓', 'Неизвестно')) + + if record.method == PaymentMethod.DONUT: + mapping = { + 'pending': ('⏳', 'Ожидает оплаты'), + 'created': ('⏳', 'Создано'), + 'processing': ('⌛', 'Обрабатывается'), + 'success': ('✅', 'Оплачено'), + 'cancelled': ('❌', 'Отменено'), + 'error': ('❌', 'Ошибка'), + 'amount_mismatch': ('⚠️', 'Несовпадение суммы'), + } + return mapping.get(status, ('❓', 'Неизвестно')) + return '❓', 'Неизвестно' diff --git a/app/config.py b/app/config.py index 92a6ae94..bad93333 100644 --- a/app/config.py +++ b/app/config.py @@ -712,6 +712,47 @@ class Settings(BaseSettings): ANTILOPAY_SBERPAY_ENABLED: bool = False ANTILOPAY_SBERPAY_DISPLAY_NAME: str = 'SberPay (Antilopay)' + # Jupiter (FPGate P2P v2.1, app.juppiter.tech) + JUPITER_ENABLED: bool = False + JUPITER_TOKEN: str | None = None + JUPITER_SECRET: str | None = None + JUPITER_BASE_URL: str = 'https://app.juppiter.tech' + JUPITER_METHOD_ID: str | None = None + JUPITER_METHOD_DESCRIPTION: str = 'SBP' + JUPITER_DISPLAY_NAME: str = 'Jupiter' + JUPITER_CURRENCY: str = 'RUB' + JUPITER_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽ + JUPITER_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽ + JUPITER_WEBHOOK_PATH: str = '/jupiter-webhook' + JUPITER_RETURN_URL: str | None = None + JUPITER_PAYMENT_LIFETIME_MINUTES: int = 60 + JUPITER_FALLBACK_EMAIL: str = 'user@vpn.bot' + JUPITER_FALLBACK_PHONE: str = '0000000000' + JUPITER_FALLBACK_NAME: str = 'User' + JUPITER_SBP_ENABLED: bool = False + JUPITER_SBP_DISPLAY_NAME: str = 'СБП (Jupiter)' + + # Donut (Donut P2P, gw.donut.business) + DONUT_ENABLED: bool = False + DONUT_TOKEN: str | None = None + DONUT_SECRET: str | None = None + DONUT_BASE_URL: str = 'https://gw.donut.business' + DONUT_METHOD_ID: str | None = None + DONUT_DISPLAY_NAME: str = 'Donut' + DONUT_CURRENCY: str = 'RUB' + DONUT_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽ + DONUT_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽ + DONUT_WEBHOOK_PATH: str = '/donut-webhook' + DONUT_RETURN_URL: str | None = None + DONUT_PAYMENT_LIFETIME_MINUTES: int = 60 + # Sub-методы Donut (description в PayIn запросе) + DONUT_CARD_ENABLED: bool = False + DONUT_CARD_DISPLAY_NAME: str = 'Карта (Donut)' + DONUT_SBP_ENABLED: bool = False + DONUT_SBP_DISPLAY_NAME: str = 'СБП (Donut)' + DONUT_SBP_QR_ENABLED: bool = False + DONUT_SBP_QR_DISPLAY_NAME: str = 'СБП QR (Donut)' + # Etoplatezhi (paymentpage.etoplatezhi.ru) ETOPLATEZHI_ENABLED: bool = False ETOPLATEZHI_PROJECT_ID: int | None = None @@ -2234,6 +2275,74 @@ class Settings(BaseSettings): def get_antilopay_sberpay_display_name_html(self) -> str: return html.escape(self.get_antilopay_sberpay_display_name()) + def is_jupiter_enabled(self) -> bool: + return ( + self.JUPITER_ENABLED + and self.JUPITER_TOKEN is not None + and self.JUPITER_SECRET is not None + ) + + def get_jupiter_display_name(self) -> str: + name = (self.JUPITER_DISPLAY_NAME or '').strip() + return name if name else 'Jupiter' + + def get_jupiter_display_name_html(self) -> str: + return html.escape(self.get_jupiter_display_name()) + + def is_jupiter_sbp_enabled(self) -> bool: + return self.JUPITER_SBP_ENABLED and self.is_jupiter_enabled() + + def get_jupiter_sbp_display_name(self) -> str: + name = (self.JUPITER_SBP_DISPLAY_NAME or '').strip() + return name or 'СБП (Jupiter)' + + def get_jupiter_sbp_display_name_html(self) -> str: + return html.escape(self.get_jupiter_sbp_display_name()) + + def is_donut_enabled(self) -> bool: + return ( + self.DONUT_ENABLED + and self.DONUT_TOKEN is not None + and self.DONUT_SECRET is not None + ) + + def get_donut_display_name(self) -> str: + name = (self.DONUT_DISPLAY_NAME or '').strip() + return name if name else 'Donut' + + def get_donut_display_name_html(self) -> str: + return html.escape(self.get_donut_display_name()) + + def is_donut_card_enabled(self) -> bool: + return self.DONUT_CARD_ENABLED and self.is_donut_enabled() + + def get_donut_card_display_name(self) -> str: + name = (self.DONUT_CARD_DISPLAY_NAME or '').strip() + return name or 'Карта (Donut)' + + def get_donut_card_display_name_html(self) -> str: + return html.escape(self.get_donut_card_display_name()) + + def is_donut_sbp_enabled(self) -> bool: + return self.DONUT_SBP_ENABLED and self.is_donut_enabled() + + def get_donut_sbp_display_name(self) -> str: + name = (self.DONUT_SBP_DISPLAY_NAME or '').strip() + return name or 'СБП (Donut)' + + def get_donut_sbp_display_name_html(self) -> str: + return html.escape(self.get_donut_sbp_display_name()) + + def is_donut_sbp_qr_enabled(self) -> bool: + return self.DONUT_SBP_QR_ENABLED and self.is_donut_enabled() + + def get_donut_sbp_qr_display_name(self) -> str: + name = (self.DONUT_SBP_QR_DISPLAY_NAME or '').strip() + return name or 'СБП QR (Donut)' + + def get_donut_sbp_qr_display_name_html(self) -> str: + return html.escape(self.get_donut_sbp_qr_display_name()) + def is_etoplatezhi_enabled(self) -> bool: return ( self.ETOPLATEZHI_ENABLED diff --git a/app/database/crud/donut.py b/app/database/crud/donut.py new file mode 100644 index 00000000..0d75b313 --- /dev/null +++ b/app/database/crud/donut.py @@ -0,0 +1,159 @@ +"""CRUD операции для платежей Donut (Donut P2P).""" + +from datetime import UTC, datetime + +import structlog +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import DonutPayment + + +logger = structlog.get_logger(__name__) + + +async def create_donut_payment( + db: AsyncSession, + *, + user_id: int | None, + order_id: str, + amount_kopeks: int, + currency: str = 'RUB', + description: str | None = None, + payment_url: str | None = None, + payment_method: str | None = None, + donut_transaction_id: str | None = None, + expires_at: datetime | None = None, + metadata_json: dict | None = None, +) -> DonutPayment: + """Создаёт запись о платеже Donut.""" + payment = DonutPayment( + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=payment_method, + donut_transaction_id=donut_transaction_id, + expires_at=expires_at, + metadata_json=metadata_json, + status='pending', + is_paid=False, + ) + db.add(payment) + await db.commit() + await db.refresh(payment) + logger.info('Создан платеж Donut', order_id=order_id, user_id=user_id) + return payment + + +async def get_donut_payment_by_order_id(db: AsyncSession, order_id: str) -> DonutPayment | None: + """Получает платеж по order_id (internal).""" + result = await db.execute(select(DonutPayment).where(DonutPayment.order_id == order_id)) + return result.scalar_one_or_none() + + +async def get_donut_payment_by_invoice_id( + db: AsyncSession, donut_transaction_id: str +) -> DonutPayment | None: + """Получает платёж по transaction_id, выданному Donut.""" + result = await db.execute( + select(DonutPayment).where(DonutPayment.donut_transaction_id == donut_transaction_id) + ) + return result.scalar_one_or_none() + + +async def get_donut_payment_by_id(db: AsyncSession, payment_id: int) -> DonutPayment | None: + """Получает платеж по локальному ID.""" + result = await db.execute(select(DonutPayment).where(DonutPayment.id == payment_id)) + return result.scalar_one_or_none() + + +async def get_donut_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> DonutPayment | None: + """Получает платёж с блокировкой FOR UPDATE.""" + result = await db.execute( + select(DonutPayment) + .where(DonutPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return result.scalar_one_or_none() + + +async def update_donut_payment_status( + db: AsyncSession, + payment: DonutPayment, + *, + status: str, + is_paid: bool | None = None, + donut_transaction_id: str | None = None, + payment_method: str | None = None, + callback_payload: dict | None = None, + transaction_id: int | None = None, +) -> DonutPayment: + """Обновляет статус платежа.""" + payment.status = status + payment.updated_at = datetime.now(UTC) + + if is_paid is not None: + payment.is_paid = is_paid + if is_paid: + payment.paid_at = datetime.now(UTC) + if donut_transaction_id is not None: + payment.donut_transaction_id = donut_transaction_id + if payment_method is not None: + payment.payment_method = payment_method + if callback_payload is not None: + payment.callback_payload = callback_payload + if transaction_id is not None: + payment.transaction_id = transaction_id + + await db.commit() + await db.refresh(payment) + logger.info( + 'Обновлён статус платежа Donut', + order_id=payment.order_id, + status=status, + is_paid=payment.is_paid, + ) + return payment + + +async def get_pending_donut_payments(db: AsyncSession, user_id: int) -> list[DonutPayment]: + """Возвращает незавершённые платежи пользователя.""" + result = await db.execute( + select(DonutPayment).where( + DonutPayment.user_id == user_id, + DonutPayment.status == 'pending', + DonutPayment.is_paid == False, + ) + ) + return list(result.scalars().all()) + + +async def get_expired_pending_donut_payments(db: AsyncSession) -> list[DonutPayment]: + """Возвращает просроченные платежи в статусе pending.""" + now = datetime.now(UTC) + result = await db.execute( + select(DonutPayment).where( + DonutPayment.status == 'pending', + DonutPayment.is_paid == False, + DonutPayment.expires_at < now, + ) + ) + return list(result.scalars().all()) + + +async def link_donut_payment_to_transaction( + db: AsyncSession, + *, + payment: DonutPayment, + transaction_id: int, +) -> DonutPayment: + """Связывает платёж с транзакцией.""" + payment.transaction_id = transaction_id + payment.updated_at = datetime.now(UTC) + await db.flush() + await db.refresh(payment) + return payment diff --git a/app/database/crud/jupiter.py b/app/database/crud/jupiter.py new file mode 100644 index 00000000..674cef53 --- /dev/null +++ b/app/database/crud/jupiter.py @@ -0,0 +1,159 @@ +"""CRUD операции для платежей Jupiter (FPGate P2P v2.1).""" + +from datetime import UTC, datetime + +import structlog +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import JupiterPayment + + +logger = structlog.get_logger(__name__) + + +async def create_jupiter_payment( + db: AsyncSession, + *, + user_id: int | None, + order_id: str, + amount_kopeks: int, + currency: str = 'RUB', + description: str | None = None, + payment_url: str | None = None, + payment_method: str | None = None, + jupiter_transaction_id: str | None = None, + expires_at: datetime | None = None, + metadata_json: dict | None = None, +) -> JupiterPayment: + """Создаёт запись о платеже Jupiter.""" + payment = JupiterPayment( + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=payment_method, + jupiter_transaction_id=jupiter_transaction_id, + expires_at=expires_at, + metadata_json=metadata_json, + status='pending', + is_paid=False, + ) + db.add(payment) + await db.commit() + await db.refresh(payment) + logger.info('Создан платеж Jupiter', order_id=order_id, user_id=user_id) + return payment + + +async def get_jupiter_payment_by_order_id(db: AsyncSession, order_id: str) -> JupiterPayment | None: + """Получает платеж по order_id (internal).""" + result = await db.execute(select(JupiterPayment).where(JupiterPayment.order_id == order_id)) + return result.scalar_one_or_none() + + +async def get_jupiter_payment_by_invoice_id( + db: AsyncSession, jupiter_transaction_id: str +) -> JupiterPayment | None: + """Получает платёж по transaction_id, выданному Jupiter.""" + result = await db.execute( + select(JupiterPayment).where(JupiterPayment.jupiter_transaction_id == jupiter_transaction_id) + ) + return result.scalar_one_or_none() + + +async def get_jupiter_payment_by_id(db: AsyncSession, payment_id: int) -> JupiterPayment | None: + """Получает платеж по локальному ID.""" + result = await db.execute(select(JupiterPayment).where(JupiterPayment.id == payment_id)) + return result.scalar_one_or_none() + + +async def get_jupiter_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> JupiterPayment | None: + """Получает платёж с блокировкой FOR UPDATE.""" + result = await db.execute( + select(JupiterPayment) + .where(JupiterPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return result.scalar_one_or_none() + + +async def update_jupiter_payment_status( + db: AsyncSession, + payment: JupiterPayment, + *, + status: str, + is_paid: bool | None = None, + jupiter_transaction_id: str | None = None, + payment_method: str | None = None, + callback_payload: dict | None = None, + transaction_id: int | None = None, +) -> JupiterPayment: + """Обновляет статус платежа.""" + payment.status = status + payment.updated_at = datetime.now(UTC) + + if is_paid is not None: + payment.is_paid = is_paid + if is_paid: + payment.paid_at = datetime.now(UTC) + if jupiter_transaction_id is not None: + payment.jupiter_transaction_id = jupiter_transaction_id + if payment_method is not None: + payment.payment_method = payment_method + if callback_payload is not None: + payment.callback_payload = callback_payload + if transaction_id is not None: + payment.transaction_id = transaction_id + + await db.commit() + await db.refresh(payment) + logger.info( + 'Обновлён статус платежа Jupiter', + order_id=payment.order_id, + status=status, + is_paid=payment.is_paid, + ) + return payment + + +async def get_pending_jupiter_payments(db: AsyncSession, user_id: int) -> list[JupiterPayment]: + """Возвращает незавершённые платежи пользователя.""" + result = await db.execute( + select(JupiterPayment).where( + JupiterPayment.user_id == user_id, + JupiterPayment.status == 'pending', + JupiterPayment.is_paid == False, + ) + ) + return list(result.scalars().all()) + + +async def get_expired_pending_jupiter_payments(db: AsyncSession) -> list[JupiterPayment]: + """Возвращает просроченные платежи в статусе pending.""" + now = datetime.now(UTC) + result = await db.execute( + select(JupiterPayment).where( + JupiterPayment.status == 'pending', + JupiterPayment.is_paid == False, + JupiterPayment.expires_at < now, + ) + ) + return list(result.scalars().all()) + + +async def link_jupiter_payment_to_transaction( + db: AsyncSession, + *, + payment: JupiterPayment, + transaction_id: int, +) -> JupiterPayment: + """Связывает платёж с транзакцией.""" + payment.transaction_id = transaction_id + payment.updated_at = datetime.now(UTC) + await db.flush() + await db.refresh(payment) + return payment diff --git a/app/database/models.py b/app/database/models.py index 93c3de12..a3190f20 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -169,6 +169,8 @@ class PaymentMethod(Enum): AURAPAY = 'aurapay' ETOPLATEZHI = 'etoplatezhi' ANTILOPAY = 'antilopay' + JUPITER = 'jupiter' + DONUT = 'donut' MANUAL = 'manual' BALANCE = 'balance' @@ -1292,6 +1294,130 @@ class AntilopayPayment(Base): return f'' +class JupiterPayment(Base): + """Платежи через Jupiter (FPGate P2P v2.1, app.juppiter.tech).""" + + __tablename__ = 'jupiter_payments' + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True) + + # Идентификаторы + order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID + jupiter_transaction_id = Column(String(128), unique=True, nullable=True, index=True) # transaction_id от Jupiter + + # Суммы + amount_kopeks = Column(Integer, nullable=False) + currency = Column(String(10), nullable=False, default='RUB') + description = Column(Text, nullable=True) + + # Статусы + status = Column(String(32), nullable=False, default='pending') + is_paid = Column(Boolean, default=False) + + # Данные платежа + payment_url = Column(Text, nullable=True) # qrcode_url из details (если есть) + payment_method = Column(String(32), nullable=True) # 'sbp' и т.д. + + # Метаданные + metadata_json = Column(JSON, nullable=True) + callback_payload = Column(JSON, nullable=True) + + # Временные метки + paid_at = Column(AwareDateTime(), nullable=True) + expires_at = Column(AwareDateTime(), nullable=True) + created_at = Column(AwareDateTime(), default=func.now()) + updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now()) + + # Связь с транзакцией + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) + + # Relationships + user = relationship('User', backref='jupiter_payments') + transaction = relationship('Transaction', backref='jupiter_payment') + + @property + def amount_rubles(self) -> float: + return self.amount_kopeks / 100 + + @property + def is_pending(self) -> bool: + return self.status == 'pending' + + @property + def is_success(self) -> bool: + return self.status == 'success' and self.is_paid + + @property + def is_failed(self) -> bool: + return self.status in ['failed', 'expired', 'cancelled', 'amount_mismatch', 'declined', 'error'] + + def __repr__(self) -> str: # pragma: no cover - debug helper + return f'' + + +class DonutPayment(Base): + """Платежи через Donut P2P (gw.donut.business).""" + + __tablename__ = 'donut_payments' + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True) + + # Идентификаторы + order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID + donut_transaction_id = Column(String(128), unique=True, nullable=True, index=True) # transaction_id от Donut + + # Суммы + amount_kopeks = Column(Integer, nullable=False) + currency = Column(String(10), nullable=False, default='RUB') + description = Column(Text, nullable=True) + + # Статусы + status = Column(String(32), nullable=False, default='pending') + is_paid = Column(Boolean, default=False) + + # Данные платежа + payment_url = Column(Text, nullable=True) # redirect_url или qrcode_url + payment_method = Column(String(32), nullable=True) # 'card', 'sbp', 'sbp_qr' + + # Метаданные + metadata_json = Column(JSON, nullable=True) + callback_payload = Column(JSON, nullable=True) + + # Временные метки + paid_at = Column(AwareDateTime(), nullable=True) + expires_at = Column(AwareDateTime(), nullable=True) + created_at = Column(AwareDateTime(), default=func.now()) + updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now()) + + # Связь с транзакцией + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) + + # Relationships + user = relationship('User', backref='donut_payments') + transaction = relationship('Transaction', backref='donut_payment') + + @property + def amount_rubles(self) -> float: + return self.amount_kopeks / 100 + + @property + def is_pending(self) -> bool: + return self.status in ('pending', 'created', 'processing') + + @property + def is_success(self) -> bool: + return self.status == 'success' and self.is_paid + + @property + def is_failed(self) -> bool: + return self.status in ['failed', 'expired', 'cancelled', 'amount_mismatch', 'error'] + + def __repr__(self) -> str: # pragma: no cover - debug helper + return f'' + + class PromoGroup(Base): __tablename__ = 'promo_groups' diff --git a/app/handlers/balance/donut.py b/app/handlers/balance/donut.py new file mode 100644 index 00000000..917e9a66 --- /dev/null +++ b/app/handlers/balance/donut.py @@ -0,0 +1,305 @@ +"""Handler for Donut balance top-up (Donut P2P).""" + +import html + +import structlog +from aiogram import types +from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.keyboards.inline import get_back_keyboard +from app.localization.texts import get_texts +from app.services.payment_service import PaymentService +from app.states import BalanceStates +from app.utils.decorators import error_handler + + +logger = structlog.get_logger(__name__) + + +DONUT_PAYMENT_METHODS = {'donut', 'donut_card', 'donut_sbp', 'donut_sbp_qr'} + +DONUT_SERVICE_MAP: dict[str, str | None] = { + 'donut': None, + 'donut_card': 'card', + 'donut_sbp': 'sbp', + 'donut_sbp_qr': 'sbp_qr', +} + + +def _extract_service_type(payment_method: str) -> str | None: + return DONUT_SERVICE_MAP.get(payment_method) + + +def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None: + """Проверяет ограничение на пополнение.""" + if not getattr(db_user, 'restriction_topup', False): + return None + + keyboard = [] + support_url = settings.get_support_contact_url() + if support_url: + keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def _get_display_name(payment_method: str) -> str: + if payment_method == 'donut_card': + return settings.get_donut_card_display_name() + if payment_method == 'donut_sbp': + return settings.get_donut_sbp_display_name() + if payment_method == 'donut_sbp_qr': + return settings.get_donut_sbp_qr_display_name() + return settings.get_donut_display_name() + + +async def _create_donut_payment_and_respond( + message_or_callback, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + edit_message: bool = False, + payment_method_type: str | None = None, + display_name: str | None = None, +): + """Создаёт платёж Donut и отправляет ссылку пользователю.""" + texts = get_texts(db_user.language) + amount_rub = amount_kopeks / 100 + + payment_service = PaymentService() + description = settings.PAYMENT_BALANCE_TEMPLATE.format( + service_name=settings.PAYMENT_SERVICE_NAME, + description='Пополнение баланса', + ) + + result = await payment_service.create_donut_payment( + db=db, + user_id=db_user.id, + amount_kopeks=amount_kopeks, + description=description, + email=getattr(db_user, 'email', None), + language=db_user.language, + payment_method_type=payment_method_type, + ) + + if not result: + error_text = texts.t( + 'PAYMENT_CREATE_ERROR', + 'Не удалось создать платёж. Попробуйте позже.', + ) + if edit_message: + await message_or_callback.edit_text( + error_text, + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + else: + await message_or_callback.answer(error_text, parse_mode='HTML') + return + + payment_url = result.get('payment_url') + name = display_name or settings.get_donut_display_name() + + pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}₽').format( + amount=f'{amount_rub:.0f}', + ) + + keyboard_buttons: list[list[InlineKeyboardButton]] = [] + if payment_url: + keyboard_buttons.append([InlineKeyboardButton(text=pay_button_text, url=payment_url)]) + keyboard_buttons.append( + [ + InlineKeyboardButton( + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', + ) + ] + ) + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + + if payment_url: + response_text = texts.t( + 'DONUT_PAYMENT_CREATED', + '\U0001f4b3 Оплата через {name}\n\n' + 'Сумма: {amount}₽\n\n' + 'Нажмите кнопку ниже для перехода к оплате.\n' + 'После подтверждения платежа баланс будет пополнен автоматически.', + ).format(name=name, amount=f'{amount_rub:.2f}') + else: + response_text = texts.t( + 'DONUT_PAYMENT_PROCESSING', + '\U0001f4b3 Платёж создан через {name}\n\n' + 'Сумма: {amount}₽\n\n' + 'Платёж в обработке. Реквизиты будут отправлены отдельным сообщением.', + ).format(name=name, amount=f'{amount_rub:.2f}') + + if edit_message: + await message_or_callback.edit_text(response_text, reply_markup=keyboard, parse_mode='HTML') + else: + await message_or_callback.answer(response_text, reply_markup=keyboard, parse_mode='HTML') + + logger.info('Donut payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub) + + +@error_handler +async def process_donut_payment_amount( + message: types.Message, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + state: FSMContext, +): + """Обрабатывает сумму, введённую пользователем для Donut.""" + texts = get_texts(db_user.language) + + restriction_kb = _check_topup_restriction(db_user, texts) + if restriction_kb: + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + await message.answer( + f'\U0001f6ab Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=restriction_kb, + ) + await state.clear() + return + + min_amount = settings.DONUT_MIN_AMOUNT_KOPEKS + max_amount = settings.DONUT_MAX_AMOUNT_KOPEKS + + if amount_kopeks < min_amount: + await message.answer( + texts.t( + 'PAYMENT_AMOUNT_TOO_LOW', + 'Минимальная сумма пополнения: {min_amount}₽', + ).format(min_amount=min_amount // 100), + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + return + + if amount_kopeks > max_amount: + await message.answer( + texts.t( + 'PAYMENT_AMOUNT_TOO_HIGH', + 'Максимальная сумма пополнения: {max_amount}₽', + ).format(max_amount=max_amount // 100), + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + return + + data = await state.get_data() + payment_method = data.get('payment_method', 'donut') + payment_method_type = _extract_service_type(payment_method) + display_name = _get_display_name(payment_method) + + await state.clear() + + await _create_donut_payment_and_respond( + message_or_callback=message, + db_user=db_user, + db=db, + amount_kopeks=amount_kopeks, + edit_message=False, + payment_method_type=payment_method_type, + display_name=display_name, + ) + + +async def _start_donut_topup_impl( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + payment_method: str, +): + """Стартует FSM ввода суммы для Donut.""" + texts = get_texts(db_user.language) + + restriction_kb = _check_topup_restriction(db_user, texts) + if restriction_kb: + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + await callback.message.edit_text( + f'\U0001f6ab Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=restriction_kb, + ) + return + + await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data(payment_method=payment_method) + + min_amount = settings.DONUT_MIN_AMOUNT_KOPEKS // 100 + max_amount = settings.DONUT_MAX_AMOUNT_KOPEKS // 100 + + display_name = _get_display_name(payment_method) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', + ) + ] + ] + ) + + await callback.message.edit_text( + texts.t( + 'DONUT_ENTER_AMOUNT', + '\U0001f4b3 Пополнение через {name}\n\n' + 'Введите сумму пополнения в рублях.\n\n' + 'Минимум: {min_amount}₽\n' + 'Максимум: {max_amount}₽', + ).format( + name=display_name, + min_amount=min_amount, + max_amount=f'{max_amount:,}'.replace(',', ' '), + ), + parse_mode='HTML', + reply_markup=keyboard, + ) + + +@error_handler +async def start_donut_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_donut_topup_impl(callback, db_user, state, 'donut') + + +@error_handler +async def start_donut_card_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_donut_topup_impl(callback, db_user, state, 'donut_card') + + +@error_handler +async def start_donut_sbp_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_donut_topup_impl(callback, db_user, state, 'donut_sbp') + + +@error_handler +async def start_donut_sbp_qr_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_donut_topup_impl(callback, db_user, state, 'donut_sbp_qr') diff --git a/app/handlers/balance/jupiter.py b/app/handlers/balance/jupiter.py new file mode 100644 index 00000000..f1120eca --- /dev/null +++ b/app/handlers/balance/jupiter.py @@ -0,0 +1,273 @@ +"""Handler for Jupiter balance top-up (FPGate P2P v2.1).""" + +import html + +import structlog +from aiogram import types +from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.keyboards.inline import get_back_keyboard +from app.localization.texts import get_texts +from app.services.payment_service import PaymentService +from app.states import BalanceStates +from app.utils.decorators import error_handler + + +logger = structlog.get_logger(__name__) + + +JUPITER_PAYMENT_METHODS = {'jupiter', 'jupiter_sbp'} + +JUPITER_SERVICE_MAP: dict[str, str | None] = { + 'jupiter': None, + 'jupiter_sbp': 'sbp', +} + + +def _extract_service_type(payment_method: str) -> str | None: + return JUPITER_SERVICE_MAP.get(payment_method) + + +def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None: + """Проверяет ограничение на пополнение.""" + if not getattr(db_user, 'restriction_topup', False): + return None + + keyboard = [] + support_url = settings.get_support_contact_url() + if support_url: + keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +async def _create_jupiter_payment_and_respond( + message_or_callback, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + edit_message: bool = False, + payment_method_type: str | None = None, +): + """Создаёт платёж Jupiter и отправляет ссылку/QR пользователю.""" + texts = get_texts(db_user.language) + amount_rub = amount_kopeks / 100 + + payment_service = PaymentService() + description = settings.PAYMENT_BALANCE_TEMPLATE.format( + service_name=settings.PAYMENT_SERVICE_NAME, + description='Пополнение баланса', + ) + + result = await payment_service.create_jupiter_payment( + db=db, + user_id=db_user.id, + amount_kopeks=amount_kopeks, + description=description, + email=getattr(db_user, 'email', None), + language=db_user.language, + payment_method_type=payment_method_type, + ) + + if not result: + error_text = texts.t( + 'PAYMENT_CREATE_ERROR', + 'Не удалось создать платёж. Попробуйте позже.', + ) + if edit_message: + await message_or_callback.edit_text( + error_text, + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + else: + await message_or_callback.answer(error_text, parse_mode='HTML') + return + + payment_url = result.get('payment_url') + display_name = settings.get_jupiter_display_name() + + pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}₽').format( + amount=f'{amount_rub:.0f}', + ) + + keyboard_buttons: list[list[InlineKeyboardButton]] = [] + if payment_url: + keyboard_buttons.append([InlineKeyboardButton(text=pay_button_text, url=payment_url)]) + keyboard_buttons.append( + [ + InlineKeyboardButton( + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', + ) + ] + ) + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + + if payment_url: + response_text = texts.t( + 'JUPITER_PAYMENT_CREATED', + '\U0001f4b3 Оплата через {name}\n\n' + 'Сумма: {amount}₽\n\n' + 'Нажмите кнопку ниже, чтобы открыть QR-код СБП и оплатить.\n' + 'Баланс будет пополнен автоматически после подтверждения платежа.', + ).format(name=display_name, amount=f'{amount_rub:.2f}') + else: + response_text = texts.t( + 'JUPITER_PAYMENT_PROCESSING', + '\U0001f4b3 Платёж создан через {name}\n\n' + 'Сумма: {amount}₽\n\n' + 'Платёж в обработке. Реквизиты будут отправлены отдельным сообщением.', + ).format(name=display_name, amount=f'{amount_rub:.2f}') + + if edit_message: + await message_or_callback.edit_text(response_text, reply_markup=keyboard, parse_mode='HTML') + else: + await message_or_callback.answer(response_text, reply_markup=keyboard, parse_mode='HTML') + + logger.info('Jupiter payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub) + + +@error_handler +async def process_jupiter_payment_amount( + message: types.Message, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + state: FSMContext, +): + """Обрабатывает сумму, введённую пользователем для Jupiter.""" + texts = get_texts(db_user.language) + + restriction_kb = _check_topup_restriction(db_user, texts) + if restriction_kb: + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + await message.answer( + f'\U0001f6ab Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=restriction_kb, + ) + await state.clear() + return + + min_amount = settings.JUPITER_MIN_AMOUNT_KOPEKS + max_amount = settings.JUPITER_MAX_AMOUNT_KOPEKS + + if amount_kopeks < min_amount: + await message.answer( + texts.t( + 'PAYMENT_AMOUNT_TOO_LOW', + 'Минимальная сумма пополнения: {min_amount}₽', + ).format(min_amount=min_amount // 100), + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + return + + if amount_kopeks > max_amount: + await message.answer( + texts.t( + 'PAYMENT_AMOUNT_TOO_HIGH', + 'Максимальная сумма пополнения: {max_amount}₽', + ).format(max_amount=max_amount // 100), + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + return + + data = await state.get_data() + payment_method = data.get('payment_method', 'jupiter') + payment_method_type = _extract_service_type(payment_method) + + await state.clear() + + await _create_jupiter_payment_and_respond( + message_or_callback=message, + db_user=db_user, + db=db, + amount_kopeks=amount_kopeks, + edit_message=False, + payment_method_type=payment_method_type, + ) + + +async def _start_jupiter_topup_impl( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + payment_method: str, +): + """Стартует FSM ввода суммы для Jupiter.""" + texts = get_texts(db_user.language) + + restriction_kb = _check_topup_restriction(db_user, texts) + if restriction_kb: + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + await callback.message.edit_text( + f'\U0001f6ab Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=restriction_kb, + ) + return + + await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data(payment_method=payment_method) + + min_amount = settings.JUPITER_MIN_AMOUNT_KOPEKS // 100 + max_amount = settings.JUPITER_MAX_AMOUNT_KOPEKS // 100 + + if payment_method == 'jupiter_sbp': + display_name = settings.get_jupiter_sbp_display_name() + else: + display_name = settings.get_jupiter_display_name() + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', + ) + ] + ] + ) + + await callback.message.edit_text( + texts.t( + 'JUPITER_ENTER_AMOUNT', + '\U0001f4b3 Пополнение через {name}\n\n' + 'Введите сумму пополнения в рублях.\n\n' + 'Минимум: {min_amount}₽\n' + 'Максимум: {max_amount}₽', + ).format( + name=display_name, + min_amount=min_amount, + max_amount=f'{max_amount:,}'.replace(',', ' '), + ), + parse_mode='HTML', + reply_markup=keyboard, + ) + + +@error_handler +async def start_jupiter_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_jupiter_topup_impl(callback, db_user, state, 'jupiter') + + +@error_handler +async def start_jupiter_sbp_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await _start_jupiter_topup_impl(callback, db_user, state, 'jupiter_sbp') diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 2047371d..4b871421 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -191,6 +191,20 @@ async def route_payment_by_method( await process_antilopay_payment_amount(message, db_user, db, amount_kopeks, state) return True + if payment_method in ('jupiter', 'jupiter_sbp'): + from .jupiter import process_jupiter_payment_amount + + async with AsyncSessionLocal() as db: + await process_jupiter_payment_amount(message, db_user, db, amount_kopeks, state) + return True + + if payment_method in ('donut', 'donut_card', 'donut_sbp', 'donut_sbp_qr'): + from .donut import process_donut_payment_amount + + async with AsyncSessionLocal() as db: + await process_donut_payment_amount(message, db_user, db, amount_kopeks, state) + return True + if payment_method == 'riopay': from .riopay import process_riopay_payment_amount @@ -806,6 +820,23 @@ def register_balance_handlers(dp: Dispatcher): dp.callback_query.register(start_antilopay_card_topup, F.data == 'topup_antilopay_card') dp.callback_query.register(start_antilopay_sberpay_topup, F.data == 'topup_antilopay_sberpay') + from .jupiter import start_jupiter_sbp_topup, start_jupiter_topup + + dp.callback_query.register(start_jupiter_topup, F.data == 'topup_jupiter') + dp.callback_query.register(start_jupiter_sbp_topup, F.data == 'topup_jupiter_sbp') + + from .donut import ( + start_donut_card_topup, + start_donut_sbp_qr_topup, + start_donut_sbp_topup, + start_donut_topup, + ) + + dp.callback_query.register(start_donut_topup, F.data == 'topup_donut') + dp.callback_query.register(start_donut_card_topup, F.data == 'topup_donut_card') + dp.callback_query.register(start_donut_sbp_topup, F.data == 'topup_donut_sbp') + dp.callback_query.register(start_donut_sbp_qr_topup, F.data == 'topup_donut_sbp_qr') + from .mulenpay import check_mulenpay_payment_status dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_')) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 1b9d24af..521cc7ba 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1983,6 +1983,83 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN ) has_direct_payment_methods = True + if settings.is_jupiter_sbp_enabled(): + jupiter_sbp_name = settings.get_jupiter_sbp_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_JUPITER_SBP', f'📱 {jupiter_sbp_name}'), + callback_data=_build_callback('jupiter_sbp'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_jupiter_enabled() and not settings.is_jupiter_sbp_enabled(): + jupiter_name = settings.get_jupiter_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_JUPITER', f'🪐 {jupiter_name}'), + callback_data=_build_callback('jupiter'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_donut_card_enabled(): + donut_card_name = settings.get_donut_card_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_DONUT_CARD', f'💳 {donut_card_name}'), + callback_data=_build_callback('donut_card'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_donut_sbp_enabled(): + donut_sbp_name = settings.get_donut_sbp_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_DONUT_SBP', f'📱 {donut_sbp_name}'), + callback_data=_build_callback('donut_sbp'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_donut_sbp_qr_enabled(): + donut_qr_name = settings.get_donut_sbp_qr_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_DONUT_SBP_QR', f'🏦 {donut_qr_name}'), + callback_data=_build_callback('donut_sbp_qr'), + ) + ] + ) + has_direct_payment_methods = True + + if ( + settings.is_donut_enabled() + and not settings.is_donut_card_enabled() + and not settings.is_donut_sbp_enabled() + and not settings.is_donut_sbp_qr_enabled() + ): + donut_name = settings.get_donut_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_DONUT', f'🍩 {donut_name}'), + callback_data=_build_callback('donut'), + ) + ] + ) + has_direct_payment_methods = True + if settings.is_support_topup_enabled(): keyboard.append( [ diff --git a/app/services/donut_service.py b/app/services/donut_service.py new file mode 100644 index 00000000..80c7c2a5 --- /dev/null +++ b/app/services/donut_service.py @@ -0,0 +1,266 @@ +"""Сервис для работы с API Donut (Donut P2P, gw.donut.business).""" + +import hashlib +import hmac +import json +from typing import Any + +import aiohttp +import structlog + +from app.config import settings + + +logger = structlog.get_logger(__name__) + + +class DonutAPIError(Exception): + """Ошибка API Donut.""" + + def __init__(self, status_code: int, message: str, code: str | None = None) -> None: + self.status_code = status_code + self.message = message + self.api_code = code + super().__init__(f'Donut API error ({status_code}): {message}') + + +class DonutService: + """Клиент для Donut P2P (gw.donut.business).""" + + def __init__(self) -> None: + self._session: aiohttp.ClientSession | None = None + + @property + def base_url(self) -> str: + return (settings.DONUT_BASE_URL or 'https://gw.donut.business').rstrip('/') + + @property + def token(self) -> str: + return settings.DONUT_TOKEN or '' + + @property + def secret(self) -> str: + return settings.DONUT_SECRET or '' + + @property + def method_id(self) -> str | None: + value = (settings.DONUT_METHOD_ID or '').strip() + return value or None + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30), + ) + return self._session + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + @staticmethod + def _build_signature_string(parts: list[tuple[str, Any]]) -> str: + """Собирает каноническую строку для подписи: имя=значение (без разделителя).""" + chunks: list[str] = [] + for key, value in parts: + if value is None: + continue + if isinstance(value, bool): + chunks.append(f'{key}={"true" if value else "false"}') + else: + value_str = str(value) + if value_str == '': + continue + chunks.append(f'{key}={value_str}') + return ''.join(chunks) + + def _hmac_hex(self, message: str) -> str: + """HMAC-SHA256 в hex.""" + return hmac.new( + self.secret.encode('utf-8'), + msg=message.encode('utf-8'), + digestmod=hashlib.sha256, + ).hexdigest() + + def _sign_payin(self, payload: dict[str, Any]) -> str: + amount = payload['amount'] + customer = payload['customer'] + parts: list[tuple[str, Any]] = [ + ('token', payload['token']), + ('order_id', payload['order_id']), + ('amount.value', amount['value']), + ('amount.currency', amount['currency']), + ('customer.id', customer['id']), + ('redirect', payload['redirect']), + ] + return self._hmac_hex(self._build_signature_string(parts)) + + def _sign_status(self, payload: dict[str, Any]) -> str: + parts: list[tuple[str, Any]] = [ + ('token', payload['token']), + ('transaction_id', payload['transaction_id']), + ] + return self._hmac_hex(self._build_signature_string(parts)) + + def _sign_balance(self, payload: dict[str, Any]) -> str: + parts: list[tuple[str, Any]] = [ + ('token', payload['token']), + ] + return self._hmac_hex(self._build_signature_string(parts)) + + @staticmethod + def _format_amount(amount_rubles: float) -> str: + """Сумма строго '0.00' с точкой (требование Donut P2P).""" + return f'{float(amount_rubles):.2f}' + + async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + url = f'{self.base_url}/{path.lstrip("/")}' + body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False) + try: + session = await self._get_session() + async with session.post( + url, + data=body, + headers={'Content-Type': 'application/json'}, + ) as response: + data = await response.json(content_type=None) + return data if isinstance(data, dict) else {'_raw': data} + except aiohttp.ClientError as error: + logger.exception('Donut API connection error', url=url, error=error) + raise + + async def create_payment( + self, + *, + amount_rubles: float, + order_id: str, + customer_id: str, + method_description: str, + customer_email: str | None = None, + customer_phone: str | None = None, + callback_url: str | None = None, + return_url: str | None = None, + receipt: str | None = None, + redirect: bool = True, + ) -> dict[str, Any]: + """Создаёт платёж (PayIn) через Donut P2P. + + POST /p2p_payin + """ + payload: dict[str, Any] = { + 'token': self.token, + 'order_id': order_id, + 'amount': { + 'value': self._format_amount(amount_rubles), + 'currency': (settings.DONUT_CURRENCY or 'RUB').upper(), + }, + 'customer': {'id': str(customer_id)}, + 'redirect': 'true' if redirect else 'false', + 'description': method_description, + } + + if customer_email: + payload['customer']['email'] = customer_email + if customer_phone: + payload['customer']['phone'] = customer_phone + + if self.method_id: + payload['method_id'] = self.method_id + if callback_url: + payload['callback_url'] = callback_url + if return_url: + payload['return_url'] = return_url + if receipt: + payload['receipt'] = receipt[:255] + + payload['signature'] = self._sign_payin(payload) + + logger.info( + 'Donut API create_payment', + order_id=order_id, + amount_rubles=amount_rubles, + description=method_description, + ) + + data = await self._post('/p2p_payin', payload) + status_obj = (data.get('status') or {}) if isinstance(data, dict) else {} + status_type = status_obj.get('type') + + if status_type in ('processing', 'success', 'created'): + logger.info( + 'Donut API payment created', + order_id=order_id, + transaction_id=data.get('transaction_id'), + status_type=status_type, + ) + return data + + error_code = status_obj.get('error_code') or '0' + error_msg = status_obj.get('error_description') or status_obj.get('message') or 'Unknown error' + logger.error( + 'Donut create_payment error', + error_code=error_code, + error_msg=error_msg, + response_data=data, + ) + raise DonutAPIError(200, error_msg, error_code) + + async def check_payment(self, *, transaction_id: str) -> dict[str, Any]: + """Получает статус платежа. + + POST /p2p_status + """ + payload: dict[str, Any] = { + 'token': self.token, + 'transaction_id': str(transaction_id), + } + payload['signature'] = self._sign_status(payload) + + logger.info('Donut check_payment', transaction_id=transaction_id) + return await self._post('/p2p_status', payload) + + async def get_balance(self) -> dict[str, Any]: + """Получает баланс продавца. + + POST /p2p_balance + """ + payload: dict[str, Any] = {'token': self.token} + payload['signature'] = self._sign_balance(payload) + return await self._post('/p2p_balance', payload) + + def verify_callback_signature(self, payload: dict[str, Any]) -> bool: + """Верификация подписи callback (HMAC-SHA256, hex).""" + try: + received = (payload.get('signature') or '').strip() + if not received: + logger.warning('Donut callback: отсутствует signature') + return False + + amount = payload.get('amount') or {} + status_obj = payload.get('status') or {} + parts: list[tuple[str, Any]] = [ + ('token', payload.get('token')), + ('transaction_id', payload.get('transaction_id')), + ('order_id', payload.get('order_id')), + ('amount.value', amount.get('value')), + ('amount.currency', amount.get('currency')), + ('recalculated', payload.get('recalculated')), + ('status.type', status_obj.get('type')), + ] + expected = self._hmac_hex(self._build_signature_string(parts)) + if not hmac.compare_digest(expected.lower(), received.lower()): + logger.warning( + 'Donut callback: invalid signature', + expected_prefix=expected[:8], + received_prefix=received[:8], + ) + return False + return True + except Exception as error: + logger.error('Donut callback verify error', error=error) + return False + + +# Singleton instance +donut_service = DonutService() diff --git a/app/services/jupiter_service.py b/app/services/jupiter_service.py new file mode 100644 index 00000000..26482bae --- /dev/null +++ b/app/services/jupiter_service.py @@ -0,0 +1,268 @@ +"""Сервис для работы с API Jupiter (FPGate P2P v2.1, app.juppiter.tech).""" + +import hashlib +import hmac +import json +from typing import Any + +import aiohttp +import structlog + +from app.config import settings + + +logger = structlog.get_logger(__name__) + + +class JupiterAPIError(Exception): + """Ошибка API Jupiter.""" + + def __init__(self, status_code: int, message: str, code: str | None = None) -> None: + self.status_code = status_code + self.message = message + self.api_code = code + super().__init__(f'Jupiter API error ({status_code}): {message}') + + +class JupiterService: + """Клиент для FPGate P2P v2.1 (Jupiter / app.juppiter.tech).""" + + def __init__(self) -> None: + self._session: aiohttp.ClientSession | None = None + + @property + def base_url(self) -> str: + return (settings.JUPITER_BASE_URL or 'https://app.juppiter.tech').rstrip('/') + + @property + def token(self) -> str: + return settings.JUPITER_TOKEN or '' + + @property + def secret(self) -> str: + return settings.JUPITER_SECRET or '' + + @property + def method_id(self) -> str | None: + value = (settings.JUPITER_METHOD_ID or '').strip() + return value or None + + @property + def method_description(self) -> str: + return (settings.JUPITER_METHOD_DESCRIPTION or 'SBP').strip() or 'SBP' + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30), + ) + return self._session + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + @staticmethod + def _build_signature_string(parts: list[tuple[str, Any]]) -> str: + """Собирает каноническую строку для подписи: имя=значение... в порядке полей. + + По спецификации FPGate P2P v2.1: «Если поле подписываемое, но не обязательное, + то оно входит в подпись, если оно присутствует в запросе и имеет непустое значение». + """ + chunks: list[str] = [] + for key, value in parts: + if value is None: + continue + if isinstance(value, bool): + chunks.append(f'{key}={"true" if value else "false"}') + continue + value_str = str(value) + if value_str == '': + continue + chunks.append(f'{key}={value_str}') + return ''.join(chunks) + + def _hmac_hex(self, message: str) -> str: + """HMAC-SHA256 в hex (регистр не важен по спецификации).""" + return hmac.new( + self.secret.encode('utf-8'), + msg=message.encode('utf-8'), + digestmod=hashlib.sha256, + ).hexdigest() + + def _sign_payin(self, payload: dict[str, Any]) -> str: + amount = payload['amount'] + customer = payload['customer'] + parts: list[tuple[str, Any]] = [ + ('token', payload['token']), + ('order_id', payload['order_id']), + ('amount.value', amount['value']), + ('amount.currency', amount['currency']), + ('customer.id', customer['id']), + ('redirect', payload['redirect']), + ] + return self._hmac_hex(self._build_signature_string(parts)) + + def _sign_status(self, payload: dict[str, Any]) -> str: + parts: list[tuple[str, Any]] = [ + ('token', payload['token']), + ('transaction_id', payload['transaction_id']), + ] + return self._hmac_hex(self._build_signature_string(parts)) + + def _sign_balance(self, payload: dict[str, Any]) -> str: + parts: list[tuple[str, Any]] = [ + ('token', payload['token']), + ] + return self._hmac_hex(self._build_signature_string(parts)) + + @staticmethod + def _format_amount(amount_rubles: float) -> str: + """Сумма строго '0.00' с точкой-разделителем (требование P2P v2.1).""" + return f'{float(amount_rubles):.2f}' + + async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + url = f'{self.base_url}/{path.lstrip("/")}' + body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False) + try: + session = await self._get_session() + async with session.post( + url, + data=body, + headers={'Content-Type': 'application/json'}, + ) as response: + data = await response.json(content_type=None) + return data if isinstance(data, dict) else {'_raw': data} + except aiohttp.ClientError as error: + logger.exception('Jupiter API connection error', url=url, error=error) + raise + + async def create_payment( + self, + *, + amount_rubles: float, + order_id: str, + customer_id: str, + customer_email: str | None = None, + customer_phone: str | None = None, + customer_name: str | None = None, + callback_url: str | None = None, + receipt: str | None = None, + description: str | None = None, + ) -> dict[str, Any]: + """Создаёт платёж (PayIn) согласно FPGate P2P v2.1. + + POST /p2p_payin_v2.1 + """ + payload: dict[str, Any] = { + 'token': self.token, + 'order_id': order_id, + 'amount': { + 'value': self._format_amount(amount_rubles), + 'currency': (settings.JUPITER_CURRENCY or 'RUB').upper(), + }, + 'customer': { + 'id': str(customer_id), + 'email': customer_email or settings.JUPITER_FALLBACK_EMAIL or 'user@vpn.bot', + 'phone': customer_phone or settings.JUPITER_FALLBACK_PHONE or '0000000000', + 'name': customer_name or settings.JUPITER_FALLBACK_NAME or 'User', + }, + 'redirect': 'false', + 'description': (description or self.method_description)[:255], + } + + if self.method_id: + payload['method_id'] = self.method_id + if callback_url: + payload['callback_url'] = callback_url + if receipt: + payload['receipt'] = receipt[:255] + + payload['signature'] = self._sign_payin(payload) + + logger.info('Jupiter API create_payment', order_id=order_id, amount_rubles=amount_rubles) + + data = await self._post('/p2p_payin_v2.1', payload) + status = (data.get('status') or {}) if isinstance(data, dict) else {} + status_type = status.get('type') + + if status_type in ('processing', 'success'): + logger.info( + 'Jupiter API payment created', + order_id=order_id, + transaction_id=data.get('transaction_id'), + status_type=status_type, + ) + return data + + error_code = status.get('error_code') or '0' + error_msg = status.get('error_description') or 'Unknown error' + logger.error( + 'Jupiter create_payment error', + error_code=error_code, + error_msg=error_msg, + response_data=data, + ) + raise JupiterAPIError(200, error_msg, error_code) + + async def check_payment(self, *, transaction_id: str) -> dict[str, Any]: + """Получает статус платежа. + + POST /p2p_status_v2.1 + """ + payload: dict[str, Any] = { + 'token': self.token, + 'transaction_id': str(transaction_id), + } + payload['signature'] = self._sign_status(payload) + + logger.info('Jupiter check_payment', transaction_id=transaction_id) + data = await self._post('/p2p_status_v2.1', payload) + return data + + async def get_balance(self) -> dict[str, Any]: + """Получает баланс продавца. + + POST /p2p_balance_v2.1 + """ + payload: dict[str, Any] = {'token': self.token} + payload['signature'] = self._sign_balance(payload) + data = await self._post('/p2p_balance_v2.1', payload) + return data + + def verify_callback_signature(self, payload: dict[str, Any]) -> bool: + """Верификация подписи callback (HMAC-SHA256, hex).""" + try: + received = (payload.get('signature') or '').strip() + if not received: + logger.warning('Jupiter callback: отсутствует signature') + return False + + amount = payload.get('amount') or {} + status = payload.get('status') or {} + parts: list[tuple[str, Any]] = [ + ('token', payload.get('token')), + ('transaction_id', payload.get('transaction_id')), + ('order_id', payload.get('order_id')), + ('amount.value', amount.get('value')), + ('amount.currency', amount.get('currency')), + ('recalculated', payload.get('recalculated')), + ('status.type', status.get('type')), + ] + expected = self._hmac_hex(self._build_signature_string(parts)) + if not hmac.compare_digest(expected.lower(), received.lower()): + logger.warning( + 'Jupiter callback: invalid signature', + expected_prefix=expected[:8], + received_prefix=received[:8], + ) + return False + return True + except Exception as error: + logger.error('Jupiter callback verify error', error=error) + return False + + +# Singleton instance +jupiter_service = JupiterService() diff --git a/app/services/payment/donut.py b/app/services/payment/donut.py new file mode 100644 index 00000000..e3a11b11 --- /dev/null +++ b/app/services/payment/donut.py @@ -0,0 +1,506 @@ +"""Mixin для интеграции с Donut P2P (gw.donut.business).""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta +from importlib import import_module +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import PaymentMethod, TransactionType +from app.services.donut_service import donut_service +from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info + + +# Маппинг description при PayIn (Donut) <-> наш sub-method id +DONUT_METHOD_DESCRIPTIONS: dict[str | None, str] = { + None: 'CARD', + 'card': 'CARD', + 'sbp': 'SBP', + 'sbp_qr': 'SBP_QR', +} + + +# Маппинг статусов Donut -> internal +DONUT_STATUS_MAP: dict[str, tuple[str, bool]] = { + 'created': ('pending', False), + 'processing': ('pending', False), + 'success': ('success', True), + 'cancelled': ('cancelled', False), + 'error': ('error', False), +} + + +class DonutPaymentMixin: + """Mixin для работы с платежами Donut.""" + + async def create_donut_payment( + self, + db: AsyncSession, + *, + user_id: int | None, + amount_kopeks: int, + description: str = 'Пополнение баланса', + email: str | None = None, + language: str = 'ru', + payment_method_type: str | None = None, + return_url: str | None = None, + ) -> dict[str, Any] | None: + """Создаёт платёж Donut.""" + if not settings.is_donut_enabled(): + logger.error('Donut не настроен') + return None + + if amount_kopeks < settings.DONUT_MIN_AMOUNT_KOPEKS: + logger.warning( + 'Donut: сумма меньше минимальной', + amount_kopeks=amount_kopeks, + DONUT_MIN_AMOUNT_KOPEKS=settings.DONUT_MIN_AMOUNT_KOPEKS, + ) + return None + + if amount_kopeks > settings.DONUT_MAX_AMOUNT_KOPEKS: + logger.warning( + 'Donut: сумма больше максимальной', + amount_kopeks=amount_kopeks, + DONUT_MAX_AMOUNT_KOPEKS=settings.DONUT_MAX_AMOUNT_KOPEKS, + ) + return None + + payment_module = import_module('app.services.payment_service') + if user_id is not None: + user = await payment_module.get_user_by_id(db, user_id) + tg_id = user.telegram_id if user else user_id + else: + user = None + tg_id = 'guest' + + order_id = f'dnt{tg_id}_{uuid.uuid4().hex[:6]}' + amount_rubles = amount_kopeks / 100 + currency = settings.DONUT_CURRENCY + + method_key = (payment_method_type or '').lower() or None + method_description = DONUT_METHOD_DESCRIPTIONS.get(method_key, 'CARD') + + metadata = { + 'user_id': user_id, + 'amount_kopeks': amount_kopeks, + 'description': description, + 'language': language, + 'type': 'balance_topup', + 'payment_method_type': method_key, + 'donut_description': method_description, + } + + try: + callback_url = self._build_donut_callback_url() + customer_id = str(tg_id) if tg_id != 'guest' else f'guest-{order_id[-6:]}' + actual_return_url = return_url or settings.DONUT_RETURN_URL + + api_result = await donut_service.create_payment( + amount_rubles=amount_rubles, + order_id=order_id, + customer_id=customer_id, + method_description=method_description, + customer_email=email, + customer_phone=getattr(user, 'phone', None) if user else None, + callback_url=callback_url, + return_url=actual_return_url, + redirect=True, + ) + + transaction_id = api_result.get('transaction_id') + details = api_result.get('details') or {} + payment_url = ( + api_result.get('redirect_url') + or details.get('qrcode_url') + or actual_return_url + ) + + logger.info( + 'Donut: получен ответ API', + order_id=order_id, + transaction_id=transaction_id, + payment_url=payment_url, + method_description=method_description, + ) + + lifetime = settings.DONUT_PAYMENT_LIFETIME_MINUTES + expires_at = datetime.now(UTC) + timedelta(minutes=lifetime) + + donut_crud = import_module('app.database.crud.donut') + local_payment = await donut_crud.create_donut_payment( + db=db, + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=method_key, + donut_transaction_id=str(transaction_id) if transaction_id else None, + expires_at=expires_at, + metadata_json=metadata, + ) + + logger.info( + 'Donut: создан платеж', + order_id=order_id, + user_id=user_id, + amount_rubles=amount_rubles, + currency=currency, + ) + + return { + 'order_id': order_id, + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_rubles, + 'currency': currency, + 'payment_url': payment_url, + 'payment_id': str(transaction_id) if transaction_id else None, + 'expires_at': expires_at.isoformat(), + 'local_payment_id': local_payment.id, + } + + except Exception as e: + logger.exception('Donut: ошибка создания платежа', error=e) + return None + + @staticmethod + def _build_donut_callback_url() -> str | None: + """Собирает абсолютный callback URL для Donut.""" + webhook_path = settings.DONUT_WEBHOOK_PATH or '/donut-webhook' + base = ( + getattr(settings, 'WEBHOOK_URL', None) + or getattr(settings, 'WEB_API_BASE_URL', None) + or getattr(settings, 'CABINET_URL', None) + ) + if not base: + return None + return f'{base.rstrip("/")}{webhook_path if webhook_path.startswith("/") else "/" + webhook_path}' + + async def process_donut_callback( + self, + db: AsyncSession, + payload: dict[str, Any], + ) -> bool: + """Обрабатывает callback от Donut (подпись уже проверена в webserver).""" + try: + our_order_id = payload.get('order_id') + donut_transaction_id = payload.get('transaction_id') + status_obj = payload.get('status') or {} + donut_status = (status_obj.get('type') or '').strip().lower() + + if not our_order_id or not donut_status: + logger.warning('Donut callback: отсутствуют обязательные поля', payload=payload) + return False + + donut_crud = import_module('app.database.crud.donut') + payment = await donut_crud.get_donut_payment_by_order_id(db, our_order_id) + if not payment: + logger.warning('Donut callback: платеж не найден', order_id=our_order_id) + return False + + locked = await donut_crud.get_donut_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('Donut: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + + if payment.is_paid: + logger.info('Donut callback: платеж уже обработан', order_id=payment.order_id) + return True + + # Терминальные неуспешные статусы стики — провайдер не должен иметь возможность + # «починить» отклонённый/несовпавший платёж повторным callback'ом. + if payment.status in {'amount_mismatch', 'cancelled', 'error', 'expired'}: + logger.warning( + 'Donut callback: платёж в терминальном неуспешном статусе, игнорируется', + order_id=payment.order_id, + current_status=payment.status, + incoming_status=donut_status, + ) + return True + + internal_status, is_paid = DONUT_STATUS_MAP.get(donut_status, ('pending', False)) + + callback_payload = { + 'donut_transaction_id': donut_transaction_id, + 'status_type': donut_status, + 'amount': payload.get('amount'), + 'recalculated': payload.get('recalculated'), + 'timestamp': payload.get('timestamp'), + } + + if is_paid: + amount_obj = payload.get('amount') or {} + received_value = amount_obj.get('value') + if received_value is not None: + try: + received_kopeks = round(float(received_value) * 100) + except (TypeError, ValueError): + received_kopeks = None + if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1: + logger.error( + 'Donut amount mismatch', + expected_kopeks=payment.amount_kopeks, + received_kopeks=received_kopeks, + order_id=payment.order_id, + ) + await donut_crud.update_donut_payment_status( + db=db, + payment=payment, + status='amount_mismatch', + is_paid=False, + callback_payload=callback_payload, + ) + return False + + if is_paid: + payment.status = internal_status + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.donut_transaction_id = ( + str(donut_transaction_id) if donut_transaction_id else payment.donut_transaction_id + ) + payment.callback_payload = callback_payload + payment.updated_at = datetime.now(UTC) + await db.flush() + return await self._finalize_donut_payment(db, payment, trigger='webhook') + + payment = await donut_crud.update_donut_payment_status( + db=db, + payment=payment, + status=internal_status, + is_paid=False, + callback_payload=callback_payload, + ) + return True + + except Exception as e: + logger.exception('Donut callback: ошибка обработки', error=e) + return False + + async def _finalize_donut_payment( + self, + db: AsyncSession, + payment: Any, + *, + trigger: str, + ) -> bool: + """Создаёт транзакцию, начисляет баланс и отправляет уведомления. + + FOR UPDATE lock уже взят вызывающим. + """ + payment_module = import_module('app.services.payment_service') + donut_crud = import_module('app.database.crud.donut') + + if payment.transaction_id: + logger.info( + 'Donut платеж уже связан с транзакцией', + order_id=payment.order_id, + transaction_id=payment.transaction_id, + trigger=trigger, + ) + return True + + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=metadata, + payment_amount_kopeks=payment.amount_kopeks, + provider_payment_id=payment.order_id, + provider_name='donut', + ) + if guest_result is not None: + return True + + if not payment.is_paid: + payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.updated_at = datetime.now(UTC) + + balance_already_credited = bool(metadata.get('balance_credited')) + + user = await payment_module.get_user_by_id(db, payment.user_id) + if not user: + logger.error('Пользователь не найден для Donut', user_id=payment.user_id) + return False + + await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups']) + for user_promo_group in getattr(user, 'user_promo_groups', []): + await db.refresh(user_promo_group, attribute_names=['promo_group']) + + promo_group = user.get_primary_promo_group() + subscription = getattr(user, 'subscription', None) + referrer_info = format_referrer_info(user) + + transaction_external_id = payment.order_id + + existing_transaction = None + if transaction_external_id: + existing_transaction = await payment_module.get_transaction_by_external_id( + db, + transaction_external_id, + PaymentMethod.DONUT, + ) + + display_name = settings.get_donut_display_name() + description = f'Пополнение через {display_name}' + + transaction = existing_transaction + created_transaction = False + + if not transaction: + transaction = await payment_module.create_transaction( + db, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + amount_kopeks=payment.amount_kopeks, + description=description, + payment_method=PaymentMethod.DONUT, + external_id=transaction_external_id, + is_completed=True, + created_at=getattr(payment, 'created_at', None), + commit=False, + ) + created_transaction = True + + await donut_crud.link_donut_payment_to_transaction( + db, payment=payment, transaction_id=transaction.id + ) + + should_credit_balance = created_transaction or not balance_already_credited + + if not should_credit_balance: + logger.info('Donut платеж уже зачислил баланс ранее', order_id=payment.order_id) + return True + + from app.database.crud.user import lock_user_for_update + + user = await lock_user_for_update(db, user) + + old_balance = user.balance_kopeks + was_first_topup = not user.has_made_first_topup + + user.balance_kopeks += payment.amount_kopeks + user.updated_at = datetime.now(UTC) + await db.commit() + await db.refresh(user) + + from app.database.crud.transaction import emit_transaction_side_effects + + await emit_transaction_side_effects( + db, + transaction, + amount_kopeks=payment.amount_kopeks, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + payment_method=PaymentMethod.DONUT, + external_id=transaction_external_id, + ) + + topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение' + + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup( + db, + user.id, + payment.amount_kopeks, + getattr(self, 'bot', None), + ) + except Exception as error: + logger.error('Ошибка обработки реферального пополнения Donut', error=error) + + if was_first_topup and not user.has_made_first_topup and not user.referred_by_id: + user.has_made_first_topup = True + await db.commit() + await db.refresh(user) + + if getattr(self, 'bot', None): + try: + from app.services.admin_notification_service import AdminNotificationService + + notification_service = AdminNotificationService(self.bot) + await notification_service.send_balance_topup_notification( + user, + transaction, + old_balance, + topup_status=topup_status, + referrer_info=referrer_info, + subscription=subscription, + promo_group=promo_group, + db=db, + ) + except Exception as error: + logger.error('Ошибка отправки админ уведомления Donut', error=error) + + if getattr(self, 'bot', None) and user.telegram_id: + try: + keyboard = await self.build_topup_success_keyboard(user) + await self.bot.send_message( + user.telegram_id, + ( + '✅ Пополнение успешно!\n\n' + f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n' + f'\U0001f4b3 Способ: {display_name}\n' + f'\U0001f194 Транзакция: {transaction.id}\n\n' + 'Баланс пополнен автоматически!' + ), + parse_mode='HTML', + reply_markup=keyboard, + ) + except Exception as error: + logger.error('Ошибка отправки уведомления пользователю Donut', error=error) + + try: + from app.services.payment.common import send_cart_notification_after_topup + + await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None)) + except Exception as error: + logger.error( + 'Ошибка при работе с сохраненной корзиной для пользователя', + user_id=payment.user_id, + error=error, + exc_info=True, + ) + + metadata['balance_change'] = { + 'old_balance': old_balance, + 'new_balance': user.balance_kopeks, + 'credited_at': datetime.now(UTC).isoformat(), + } + metadata['balance_credited'] = True + payment.metadata_json = metadata + await db.commit() + + logger.info( + 'Обработан Donut платеж', + order_id=payment.order_id, + user_id=payment.user_id, + trigger=trigger, + ) + + return True + + async def check_donut_payment_status( + self, + db: AsyncSession, + transaction_id: str, + ) -> dict[str, Any] | None: + """Запрос статуса платежа через API Donut.""" + try: + return await donut_service.check_payment(transaction_id=transaction_id) + except Exception as e: + logger.error('Donut: ошибка проверки статуса', transaction_id=transaction_id, error=e) + return None diff --git a/app/services/payment/jupiter.py b/app/services/payment/jupiter.py new file mode 100644 index 00000000..a29311f3 --- /dev/null +++ b/app/services/payment/jupiter.py @@ -0,0 +1,498 @@ +"""Mixin для интеграции с Jupiter (FPGate P2P v2.1, app.juppiter.tech).""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta +from importlib import import_module +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import PaymentMethod, TransactionType +from app.services.jupiter_service import jupiter_service +from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info + + +# Маппинг статусов Jupiter -> internal +JUPITER_STATUS_MAP: dict[str, tuple[str, bool]] = { + 'success': ('success', True), + 'processing': ('pending', False), + 'cancelled': ('cancelled', False), + 'declined': ('declined', False), + 'error': ('error', False), +} + + +class JupiterPaymentMixin: + """Mixin для работы с платежами Jupiter.""" + + async def create_jupiter_payment( + self, + db: AsyncSession, + *, + user_id: int | None, + amount_kopeks: int, + description: str = 'Пополнение баланса', + email: str | None = None, + language: str = 'ru', + payment_method_type: str | None = None, + return_url: str | None = None, + ) -> dict[str, Any] | None: + """Создаёт платёж Jupiter. + + Параметр ``return_url`` принимается для совместимости сигнатуры с другими провайдерами + (cabinet/routes/balance.py одинаково вызывает все ``create_*_payment``), но фактически + не используется: спецификация Jupiter v2.1 имеет поле ``redirect`` зарезервированное + для будущего использования и не поддерживает return-URL семантику. Пользователь + видит QR-код СБП и подтверждает оплату в банковском приложении. + """ + if not settings.is_jupiter_enabled(): + logger.error('Jupiter не настроен') + return None + + if amount_kopeks < settings.JUPITER_MIN_AMOUNT_KOPEKS: + logger.warning( + 'Jupiter: сумма меньше минимальной', + amount_kopeks=amount_kopeks, + JUPITER_MIN_AMOUNT_KOPEKS=settings.JUPITER_MIN_AMOUNT_KOPEKS, + ) + return None + + if amount_kopeks > settings.JUPITER_MAX_AMOUNT_KOPEKS: + logger.warning( + 'Jupiter: сумма больше максимальной', + amount_kopeks=amount_kopeks, + JUPITER_MAX_AMOUNT_KOPEKS=settings.JUPITER_MAX_AMOUNT_KOPEKS, + ) + return None + + payment_module = import_module('app.services.payment_service') + if user_id is not None: + user = await payment_module.get_user_by_id(db, user_id) + tg_id = user.telegram_id if user else user_id + else: + user = None + tg_id = 'guest' + + order_id = f'jup{tg_id}_{uuid.uuid4().hex[:6]}' + amount_rubles = amount_kopeks / 100 + currency = settings.JUPITER_CURRENCY + + metadata = { + 'user_id': user_id, + 'amount_kopeks': amount_kopeks, + 'description': description, + 'language': language, + 'type': 'balance_topup', + 'payment_method_type': payment_method_type, + } + + try: + callback_url = self._build_jupiter_callback_url() + customer_id = str(tg_id) if tg_id != 'guest' else f'guest-{order_id[-6:]}' + customer_name = ( + getattr(user, 'first_name', None) + or getattr(user, 'username', None) + or settings.JUPITER_FALLBACK_NAME + ) + + api_result = await jupiter_service.create_payment( + amount_rubles=amount_rubles, + order_id=order_id, + customer_id=customer_id, + customer_email=email, + customer_name=customer_name, + callback_url=callback_url, + description=description[:255] if description else None, + ) + + transaction_id = api_result.get('transaction_id') + details = api_result.get('details') or {} + payment_url = details.get('qrcode_url') or details.get('url') + + logger.info( + 'Jupiter: получен ответ API', + order_id=order_id, + transaction_id=transaction_id, + payment_url=payment_url, + ) + + lifetime = settings.JUPITER_PAYMENT_LIFETIME_MINUTES + expires_at = datetime.now(UTC) + timedelta(minutes=lifetime) + + jupiter_crud = import_module('app.database.crud.jupiter') + local_payment = await jupiter_crud.create_jupiter_payment( + db=db, + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=payment_method_type, + jupiter_transaction_id=str(transaction_id) if transaction_id else None, + expires_at=expires_at, + metadata_json=metadata, + ) + + logger.info( + 'Jupiter: создан платеж', + order_id=order_id, + user_id=user_id, + amount_rubles=amount_rubles, + currency=currency, + ) + + return { + 'order_id': order_id, + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_rubles, + 'currency': currency, + 'payment_url': payment_url, + 'payment_id': str(transaction_id) if transaction_id else None, + 'expires_at': expires_at.isoformat(), + 'local_payment_id': local_payment.id, + } + + except Exception as e: + logger.exception('Jupiter: ошибка создания платежа', error=e) + return None + + @staticmethod + def _build_jupiter_callback_url() -> str | None: + """Собирает абсолютный callback URL для Jupiter.""" + webhook_path = settings.JUPITER_WEBHOOK_PATH or '/jupiter-webhook' + base = ( + getattr(settings, 'WEBHOOK_URL', None) + or getattr(settings, 'WEB_API_BASE_URL', None) + or getattr(settings, 'CABINET_URL', None) + ) + if not base: + return None + return f'{base.rstrip("/")}{webhook_path if webhook_path.startswith("/") else "/" + webhook_path}' + + async def process_jupiter_callback( + self, + db: AsyncSession, + payload: dict[str, Any], + ) -> bool: + """Обрабатывает callback от Jupiter (подпись уже проверена в webserver).""" + try: + our_order_id = payload.get('order_id') + jupiter_transaction_id = payload.get('transaction_id') + status_obj = payload.get('status') or {} + jupiter_status = (status_obj.get('type') or '').strip().lower() + + if not our_order_id or not jupiter_status: + logger.warning('Jupiter callback: отсутствуют обязательные поля', payload=payload) + return False + + jupiter_crud = import_module('app.database.crud.jupiter') + payment = await jupiter_crud.get_jupiter_payment_by_order_id(db, our_order_id) + if not payment: + logger.warning('Jupiter callback: платеж не найден', order_id=our_order_id) + return False + + locked = await jupiter_crud.get_jupiter_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('Jupiter: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + + if payment.is_paid: + logger.info('Jupiter callback: платеж уже обработан', order_id=payment.order_id) + return True + + # Терминальные неуспешные статусы стики — провайдер не должен иметь возможность + # «починить» отклонённый/несовпавший платёж повторным callback'ом. + if payment.status in {'amount_mismatch', 'cancelled', 'declined', 'error', 'expired'}: + logger.warning( + 'Jupiter callback: платёж в терминальном неуспешном статусе, игнорируется', + order_id=payment.order_id, + current_status=payment.status, + incoming_status=jupiter_status, + ) + return True + + internal_status, is_paid = JUPITER_STATUS_MAP.get(jupiter_status, ('pending', False)) + + callback_payload = { + 'jupiter_transaction_id': jupiter_transaction_id, + 'status_type': jupiter_status, + 'amount': payload.get('amount'), + 'recalculated': payload.get('recalculated'), + 'timestamp': payload.get('timestamp'), + } + + # Сверяем сумму ДО обновления статуса + if is_paid: + amount_obj = payload.get('amount') or {} + received_value = amount_obj.get('value') + if received_value is not None: + try: + received_kopeks = round(float(received_value) * 100) + except (TypeError, ValueError): + received_kopeks = None + if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1: + logger.error( + 'Jupiter amount mismatch', + expected_kopeks=payment.amount_kopeks, + received_kopeks=received_kopeks, + order_id=payment.order_id, + ) + await jupiter_crud.update_jupiter_payment_status( + db=db, + payment=payment, + status='amount_mismatch', + is_paid=False, + callback_payload=callback_payload, + ) + return False + + if is_paid: + payment.status = internal_status + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.jupiter_transaction_id = ( + str(jupiter_transaction_id) if jupiter_transaction_id else payment.jupiter_transaction_id + ) + payment.callback_payload = callback_payload + payment.updated_at = datetime.now(UTC) + await db.flush() + return await self._finalize_jupiter_payment(db, payment, trigger='webhook') + + payment = await jupiter_crud.update_jupiter_payment_status( + db=db, + payment=payment, + status=internal_status, + is_paid=False, + callback_payload=callback_payload, + ) + return True + + except Exception as e: + logger.exception('Jupiter callback: ошибка обработки', error=e) + return False + + async def _finalize_jupiter_payment( + self, + db: AsyncSession, + payment: Any, + *, + trigger: str, + ) -> bool: + """Создаёт транзакцию, начисляет баланс и отправляет уведомления. + + FOR UPDATE lock уже взят вызывающим. + """ + payment_module = import_module('app.services.payment_service') + jupiter_crud = import_module('app.database.crud.jupiter') + + if payment.transaction_id: + logger.info( + 'Jupiter платеж уже связан с транзакцией', + order_id=payment.order_id, + transaction_id=payment.transaction_id, + trigger=trigger, + ) + return True + + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=metadata, + payment_amount_kopeks=payment.amount_kopeks, + provider_payment_id=payment.order_id, + provider_name='jupiter', + ) + if guest_result is not None: + return True + + if not payment.is_paid: + payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.updated_at = datetime.now(UTC) + + balance_already_credited = bool(metadata.get('balance_credited')) + + user = await payment_module.get_user_by_id(db, payment.user_id) + if not user: + logger.error('Пользователь не найден для Jupiter', user_id=payment.user_id) + return False + + await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups']) + for user_promo_group in getattr(user, 'user_promo_groups', []): + await db.refresh(user_promo_group, attribute_names=['promo_group']) + + promo_group = user.get_primary_promo_group() + subscription = getattr(user, 'subscription', None) + referrer_info = format_referrer_info(user) + + transaction_external_id = payment.order_id + + existing_transaction = None + if transaction_external_id: + existing_transaction = await payment_module.get_transaction_by_external_id( + db, + transaction_external_id, + PaymentMethod.JUPITER, + ) + + display_name = settings.get_jupiter_display_name() + description = f'Пополнение через {display_name}' + + transaction = existing_transaction + created_transaction = False + + if not transaction: + transaction = await payment_module.create_transaction( + db, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + amount_kopeks=payment.amount_kopeks, + description=description, + payment_method=PaymentMethod.JUPITER, + external_id=transaction_external_id, + is_completed=True, + created_at=getattr(payment, 'created_at', None), + commit=False, + ) + created_transaction = True + + await jupiter_crud.link_jupiter_payment_to_transaction( + db, payment=payment, transaction_id=transaction.id + ) + + should_credit_balance = created_transaction or not balance_already_credited + + if not should_credit_balance: + logger.info('Jupiter платеж уже зачислил баланс ранее', order_id=payment.order_id) + return True + + from app.database.crud.user import lock_user_for_update + + user = await lock_user_for_update(db, user) + + old_balance = user.balance_kopeks + was_first_topup = not user.has_made_first_topup + + user.balance_kopeks += payment.amount_kopeks + user.updated_at = datetime.now(UTC) + await db.commit() + await db.refresh(user) + + from app.database.crud.transaction import emit_transaction_side_effects + + await emit_transaction_side_effects( + db, + transaction, + amount_kopeks=payment.amount_kopeks, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + payment_method=PaymentMethod.JUPITER, + external_id=transaction_external_id, + ) + + topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение' + + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup( + db, + user.id, + payment.amount_kopeks, + getattr(self, 'bot', None), + ) + except Exception as error: + logger.error('Ошибка обработки реферального пополнения Jupiter', error=error) + + if was_first_topup and not user.has_made_first_topup and not user.referred_by_id: + user.has_made_first_topup = True + await db.commit() + await db.refresh(user) + + if getattr(self, 'bot', None): + try: + from app.services.admin_notification_service import AdminNotificationService + + notification_service = AdminNotificationService(self.bot) + await notification_service.send_balance_topup_notification( + user, + transaction, + old_balance, + topup_status=topup_status, + referrer_info=referrer_info, + subscription=subscription, + promo_group=promo_group, + db=db, + ) + except Exception as error: + logger.error('Ошибка отправки админ уведомления Jupiter', error=error) + + if getattr(self, 'bot', None) and user.telegram_id: + try: + keyboard = await self.build_topup_success_keyboard(user) + await self.bot.send_message( + user.telegram_id, + ( + '✅ Пополнение успешно!\n\n' + f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n' + f'\U0001f4b3 Способ: {display_name}\n' + f'\U0001f194 Транзакция: {transaction.id}\n\n' + 'Баланс пополнен автоматически!' + ), + parse_mode='HTML', + reply_markup=keyboard, + ) + except Exception as error: + logger.error('Ошибка отправки уведомления пользователю Jupiter', error=error) + + try: + from app.services.payment.common import send_cart_notification_after_topup + + await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None)) + except Exception as error: + logger.error( + 'Ошибка при работе с сохраненной корзиной для пользователя', + user_id=payment.user_id, + error=error, + exc_info=True, + ) + + metadata['balance_change'] = { + 'old_balance': old_balance, + 'new_balance': user.balance_kopeks, + 'credited_at': datetime.now(UTC).isoformat(), + } + metadata['balance_credited'] = True + payment.metadata_json = metadata + await db.commit() + + logger.info( + 'Обработан Jupiter платеж', + order_id=payment.order_id, + user_id=payment.user_id, + trigger=trigger, + ) + + return True + + async def check_jupiter_payment_status( + self, + db: AsyncSession, + transaction_id: str, + ) -> dict[str, Any] | None: + """Запрос статуса платежа через API Jupiter.""" + try: + return await jupiter_service.check_payment(transaction_id=transaction_id) + except Exception as e: + logger.error('Jupiter: ошибка проверки статуса', transaction_id=transaction_id, error=e) + return None diff --git a/app/services/payment_method_config_service.py b/app/services/payment_method_config_service.py index ab583bd4..2f2c9122 100644 --- a/app/services/payment_method_config_service.py +++ b/app/services/payment_method_config_service.py @@ -210,6 +210,26 @@ def _get_method_defaults() -> dict: {'id': 'sberpay', 'name': 'SberPay'}, ], }, + 'jupiter': { + 'default_display_name': settings.get_jupiter_display_name(), + 'is_configured': settings.is_jupiter_enabled(), + 'default_min': settings.JUPITER_MIN_AMOUNT_KOPEKS, + 'default_max': settings.JUPITER_MAX_AMOUNT_KOPEKS, + 'available_sub_options': [ + {'id': 'sbp', 'name': 'СБП'}, + ], + }, + 'donut': { + 'default_display_name': settings.get_donut_display_name(), + 'is_configured': settings.is_donut_enabled(), + 'default_min': settings.DONUT_MIN_AMOUNT_KOPEKS, + 'default_max': settings.DONUT_MAX_AMOUNT_KOPEKS, + 'available_sub_options': [ + {'id': 'card', 'name': 'Карта'}, + {'id': 'sbp', 'name': 'СБП'}, + {'id': 'sbp_qr', 'name': 'СБП QR'}, + ], + }, } @@ -258,6 +278,8 @@ DEFAULT_METHOD_ORDER = [ 'aurapay', 'etoplatezhi', 'antilopay', + 'jupiter', + 'donut', ] diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 55955910..06e3d59f 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -33,8 +33,10 @@ from app.services.payment import ( from app.services.payment.antilopay import AntilopayPaymentMixin from app.services.payment.aurapay import AuraPayPaymentMixin from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin +from app.services.payment.donut import DonutPaymentMixin from app.services.payment.etoplatezhi import EtoplatezhiPaymentMixin from app.services.payment.freekassa import FreekassaPaymentMixin +from app.services.payment.jupiter import JupiterPaymentMixin from app.services.payment.kassa_ai import KassaAiPaymentMixin from app.services.payment.overpay import OverpayPaymentMixin from app.services.payment.paypear import PayPearPaymentMixin @@ -554,6 +556,76 @@ async def link_antilopay_payment_to_transaction(*args, **kwargs): return await antilopay_crud.link_antilopay_payment_to_transaction(*args, **kwargs) +async def create_jupiter_payment(*args, **kwargs): + jupiter_crud = import_module('app.database.crud.jupiter') + return await jupiter_crud.create_jupiter_payment(*args, **kwargs) + + +async def get_jupiter_payment_by_order_id(*args, **kwargs): + jupiter_crud = import_module('app.database.crud.jupiter') + return await jupiter_crud.get_jupiter_payment_by_order_id(*args, **kwargs) + + +async def get_jupiter_payment_by_invoice_id(*args, **kwargs): + jupiter_crud = import_module('app.database.crud.jupiter') + return await jupiter_crud.get_jupiter_payment_by_invoice_id(*args, **kwargs) + + +async def get_jupiter_payment_by_id(*args, **kwargs): + jupiter_crud = import_module('app.database.crud.jupiter') + return await jupiter_crud.get_jupiter_payment_by_id(*args, **kwargs) + + +async def get_jupiter_payment_by_id_for_update(*args, **kwargs): + jupiter_crud = import_module('app.database.crud.jupiter') + return await jupiter_crud.get_jupiter_payment_by_id_for_update(*args, **kwargs) + + +async def update_jupiter_payment_status(*args, **kwargs): + jupiter_crud = import_module('app.database.crud.jupiter') + return await jupiter_crud.update_jupiter_payment_status(*args, **kwargs) + + +async def link_jupiter_payment_to_transaction(*args, **kwargs): + jupiter_crud = import_module('app.database.crud.jupiter') + return await jupiter_crud.link_jupiter_payment_to_transaction(*args, **kwargs) + + +async def create_donut_payment(*args, **kwargs): + donut_crud = import_module('app.database.crud.donut') + return await donut_crud.create_donut_payment(*args, **kwargs) + + +async def get_donut_payment_by_order_id(*args, **kwargs): + donut_crud = import_module('app.database.crud.donut') + return await donut_crud.get_donut_payment_by_order_id(*args, **kwargs) + + +async def get_donut_payment_by_invoice_id(*args, **kwargs): + donut_crud = import_module('app.database.crud.donut') + return await donut_crud.get_donut_payment_by_invoice_id(*args, **kwargs) + + +async def get_donut_payment_by_id(*args, **kwargs): + donut_crud = import_module('app.database.crud.donut') + return await donut_crud.get_donut_payment_by_id(*args, **kwargs) + + +async def get_donut_payment_by_id_for_update(*args, **kwargs): + donut_crud = import_module('app.database.crud.donut') + return await donut_crud.get_donut_payment_by_id_for_update(*args, **kwargs) + + +async def update_donut_payment_status(*args, **kwargs): + donut_crud = import_module('app.database.crud.donut') + return await donut_crud.update_donut_payment_status(*args, **kwargs) + + +async def link_donut_payment_to_transaction(*args, **kwargs): + donut_crud = import_module('app.database.crud.donut') + return await donut_crud.link_donut_payment_to_transaction(*args, **kwargs) + + # Mapping from model_name to getter function name for providers # where it differs from the standard get_{model_name}_payment_by_id pattern. _GETTER_OVERRIDES: dict[str, str] = { @@ -583,6 +655,8 @@ class PaymentService( AuraPayPaymentMixin, EtoplatezhiPaymentMixin, AntilopayPaymentMixin, + JupiterPaymentMixin, + DonutPaymentMixin, ): """Основной интерфейс платежей, делегирующий работу специализированным mixin-ам.""" @@ -1134,6 +1208,50 @@ class PaymentService( } return None + # --- Jupiter ---------------------------------------------------------- + if payment_method == 'jupiter': + if not settings.is_jupiter_enabled(): + logger.warning('Jupiter is not enabled, cannot create guest payment') + return None + + result = await self.create_jupiter_payment( + db=db, + user_id=None, + amount_kopeks=amount_kopeks, + description=description, + return_url=return_url, + ) + if result: + await _patch_guest_metadata(result['local_payment_id'], 'jupiter') + return { + 'payment_url': result.get('payment_url'), + 'payment_id': result.get('order_id'), + 'provider': 'jupiter', + } + return None + + # --- Donut ------------------------------------------------------------ + if payment_method == 'donut': + if not settings.is_donut_enabled(): + logger.warning('Donut is not enabled, cannot create guest payment') + return None + + result = await self.create_donut_payment( + db=db, + user_id=None, + amount_kopeks=amount_kopeks, + description=description, + return_url=return_url, + ) + if result: + await _patch_guest_metadata(result['local_payment_id'], 'donut') + return { + 'payment_url': result.get('payment_url'), + 'payment_id': result.get('order_id'), + 'provider': 'donut', + } + return None + # --- Telegram Stars --------------------------------------------------- if payment_method == 'telegram_stars': if not settings.TELEGRAM_STARS_ENABLED: diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 47b25c26..1ad28f50 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -95,6 +95,10 @@ class BotConfigurationService: 'ROLLYPAY': '💳 RollyPay', 'OVERPAY': '💳 Overpay', 'AURAPAY': '💳 AuraPay', + 'ANTILOPAY': '🦌 Antilopay', + 'ETOPLATEZHI': '💳 Etoplatezhi', + 'JUPITER': '🪐 Jupiter', + 'DONUT': '🍩 Donut', 'YOOKASSA': '🟣 YooKassa', 'PLATEGA': '💳 {platega_name}', 'TRIBUTE': '🎁 Tribute', @@ -159,6 +163,10 @@ class BotConfigurationService: 'ROLLYPAY': 'RollyPay: платёжный шлюз rollypay.io с СБП, картами и криптовалютой.', 'OVERPAY': 'Overpay: платёжный шлюз pay.overpay.io с mTLS и поддержкой карт и СБП.', 'AURAPAY': 'AuraPay: платёжный шлюз aurapay.tech с поддержкой карт и СБП.', + 'ANTILOPAY': 'Antilopay: lk.antilopay.com, оплата картой, СБП и SberPay.', + 'ETOPLATEZHI': 'Etoplatezhi: paymentpage.etoplatezhi.ru, оплата картой и через СБП.', + 'JUPITER': 'Jupiter (FPGate P2P v2.1): app.juppiter.tech, эквайринг СБП с HMAC-SHA256.', + 'DONUT': 'Donut P2P: gw.donut.business, P2P-оплата картой, СБП по телефону и QR.', 'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.', 'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.', 'PAL24': 'PAL24 / PayPalych подключения и лимиты.', @@ -373,6 +381,10 @@ class BotConfigurationService: 'ROLLYPAY_': 'ROLLYPAY', 'OVERPAY_': 'OVERPAY', 'AURAPAY_': 'AURAPAY', + 'ANTILOPAY_': 'ANTILOPAY', + 'ETOPLATEZHI_': 'ETOPLATEZHI', + 'JUPITER_': 'JUPITER', + 'DONUT_': 'DONUT', 'PLATEGA_': 'PLATEGA', 'MULENPAY_': 'MULENPAY', 'PAL24_': 'PAL24', diff --git a/app/webserver/payments.py b/app/webserver/payments.py index 4112a0e8..b1a6effb 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -1564,6 +1564,98 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute routes_registered = True + # Jupiter webhook (FPGate P2P v2.1) + if settings.is_jupiter_enabled(): + + @router.get(settings.JUPITER_WEBHOOK_PATH) + async def jupiter_health() -> JSONResponse: + return JSONResponse( + { + 'status': 'ok', + 'service': 'jupiter_webhook', + 'enabled': settings.is_jupiter_enabled(), + } + ) + + @router.post(settings.JUPITER_WEBHOOK_PATH) + async def jupiter_webhook(request: Request) -> JSONResponse: + try: + raw_body = await request.body() + payload = json.loads(raw_body) + except Exception as parse_error: + logger.error('Jupiter webhook: failed to parse JSON', parse_error=parse_error) + return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST) + + from app.services.jupiter_service import jupiter_service + + if not jupiter_service.verify_callback_signature(payload): + logger.warning('Jupiter webhook: invalid signature') + return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST) + + try: + success = await _process_payment_service_callback( + payment_service, + payload, + 'process_jupiter_callback', + ) + if not success: + logger.error( + 'Jupiter webhook processing failed', + transaction_id=payload.get('transaction_id'), + ) + except Exception as e: + logger.exception('Jupiter webhook processing error', error=e) + # FPGate ожидает HTTP 200 как подтверждение приёма callback + return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK) + + routes_registered = True + + # Donut webhook (Donut P2P) + if settings.is_donut_enabled(): + + @router.get(settings.DONUT_WEBHOOK_PATH) + async def donut_health() -> JSONResponse: + return JSONResponse( + { + 'status': 'ok', + 'service': 'donut_webhook', + 'enabled': settings.is_donut_enabled(), + } + ) + + @router.post(settings.DONUT_WEBHOOK_PATH) + async def donut_webhook(request: Request) -> JSONResponse: + try: + raw_body = await request.body() + payload = json.loads(raw_body) + except Exception as parse_error: + logger.error('Donut webhook: failed to parse JSON', parse_error=parse_error) + return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST) + + from app.services.donut_service import donut_service + + if not donut_service.verify_callback_signature(payload): + logger.warning('Donut webhook: invalid signature') + return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST) + + try: + success = await _process_payment_service_callback( + payment_service, + payload, + 'process_donut_callback', + ) + if not success: + logger.error( + 'Donut webhook processing failed', + transaction_id=payload.get('transaction_id'), + ) + except Exception as e: + logger.exception('Donut webhook processing error', error=e) + # Donut ожидает HTTP 200 как подтверждение приёма callback + return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK) + + routes_registered = True + if routes_registered: @router.get('/health/payment-webhooks') @@ -1590,6 +1682,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute 'aurapay_enabled': settings.is_aurapay_enabled(), 'etoplatezhi_enabled': settings.is_etoplatezhi_enabled(), 'antilopay_enabled': settings.is_antilopay_enabled(), + 'jupiter_enabled': settings.is_jupiter_enabled(), + 'donut_enabled': settings.is_donut_enabled(), } ) diff --git a/migrations/alembic/versions/0072_create_jupiter_payments.py b/migrations/alembic/versions/0072_create_jupiter_payments.py new file mode 100644 index 00000000..c49def3a --- /dev/null +++ b/migrations/alembic/versions/0072_create_jupiter_payments.py @@ -0,0 +1,45 @@ +"""create jupiter_payments table + +Revision ID: 0072 +Revises: 0071 +Create Date: 2026-05-04 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0072' +down_revision: Union[str, None] = '0071' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'jupiter_payments', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True), + sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True), + sa.Column('jupiter_transaction_id', sa.String(128), unique=True, nullable=True, index=True), + sa.Column('amount_kopeks', sa.Integer(), nullable=False), + sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(32), nullable=False, server_default='pending'), + sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('payment_url', sa.Text(), nullable=True), + sa.Column('payment_method', sa.String(32), nullable=True), + sa.Column('metadata_json', sa.JSON(), nullable=True), + sa.Column('callback_payload', sa.JSON(), nullable=True), + sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table('jupiter_payments') diff --git a/migrations/alembic/versions/0073_create_donut_payments.py b/migrations/alembic/versions/0073_create_donut_payments.py new file mode 100644 index 00000000..5d9854d9 --- /dev/null +++ b/migrations/alembic/versions/0073_create_donut_payments.py @@ -0,0 +1,45 @@ +"""create donut_payments table + +Revision ID: 0073 +Revises: 0072 +Create Date: 2026-05-04 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0073' +down_revision: Union[str, None] = '0072' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'donut_payments', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True), + sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True), + sa.Column('donut_transaction_id', sa.String(128), unique=True, nullable=True, index=True), + sa.Column('amount_kopeks', sa.Integer(), nullable=False), + sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(32), nullable=False, server_default='pending'), + sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('payment_url', sa.Text(), nullable=True), + sa.Column('payment_method', sa.String(32), nullable=True), + sa.Column('metadata_json', sa.JSON(), nullable=True), + sa.Column('callback_payload', sa.JSON(), nullable=True), + sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table('donut_payments')