Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b94d9e700 | |||
| 39a7c92cd4 | |||
| 81ebec676c | |||
| 25ea5c60fd | |||
| 29877fc93b | |||
| 5986c00fab | |||
| ecc4a6147d | |||
| 16bc1d4198 | |||
| 0f814be1b7 | |||
| 97179360c0 | |||
| 2aa5927433 | |||
| 1c696c69e3 | |||
| b531959982 |
@@ -730,6 +730,24 @@ ROLLYPAY_WEBHOOK_PATH=/rollypay-webhook
|
||||
# URL возврата после оплаты
|
||||
# ROLLYPAY_RETURN_URL=
|
||||
|
||||
# ===== AURAPAY (aurapay.tech) =====
|
||||
AURAPAY_ENABLED=false
|
||||
# API ключ (X-ApiKey header)
|
||||
AURAPAY_API_KEY=
|
||||
# UUID магазина (X-ShopId header)
|
||||
AURAPAY_SHOP_ID=
|
||||
# Секретный ключ #2 для HMAC-SHA256 верификации вебхуков
|
||||
AURAPAY_SECRET_KEY=
|
||||
AURAPAY_DISPLAY_NAME=AuraPay
|
||||
AURAPAY_CURRENCY=RUB
|
||||
AURAPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
AURAPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
AURAPAY_WEBHOOK_PATH=/aurapay-webhook
|
||||
# URL возврата после оплаты
|
||||
# AURAPAY_RETURN_URL=
|
||||
# Время жизни инвойса в минутах
|
||||
AURAPAY_PAYMENT_LIFETIME_MINUTES=60
|
||||
|
||||
# ===== WATA =====
|
||||
WATA_ENABLED=false
|
||||
WATA_BASE_URL=https://api.wata.pro
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.48.0"
|
||||
".": "3.49.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## [3.49.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.48.0...v3.49.0) (2026-04-18)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* integrate AuraPay payment provider ([9717936](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97179360c0288940b1fa2f6c21a6e1431a27536f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing RollyPay CRUD wrappers and guest payment flow ([0f814be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0f814be1b7dfaec84dde9acc402b2c1790417611))
|
||||
* align campaign top registrations revenue with period comparison ([16bc1d4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/16bc1d41989d66e105724ed846fb40ccf03322fd))
|
||||
* handle edge case when all tariffs are daily in legacy renewal ([29877fc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29877fc93bc612ee199ccb2438c90e57a3c1e9e0))
|
||||
* rate-limit daily subscription insufficient balance notifications to 6 hours ([ecc4a61](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ecc4a6147dad0c8886acd48f38e567a6c7fc8916))
|
||||
* redirect legacy users without tariff to tariff selection on renewal ([5986c00](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5986c00fab8c5fe2060d296afca72d28038ff7bd))
|
||||
* register PayPear and RollyPay in admin panel settings ([2aa5927](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2aa59274331610eec3cd84f90ddb49ee59da22ef))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add AuraPay to README with partner block ([25ea5c6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25ea5c60fdaf3cac9294b6df74142c176b1d4d04))
|
||||
* add PayPear and RollyPay to README with partner blocks ([1c696c6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1c696c69e34e668ff90c915249b7c3dc37bfd89b))
|
||||
* add PayPear and RollyPay to README with partner blocks ([b531959](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b53195998231d34b6e1c7165193e87fee6e5c293))
|
||||
|
||||
## [3.48.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.47.0...v3.48.0) (2026-04-16)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.48.0" # x-release-please-version
|
||||
ARG VERSION="v3.49.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ Bedolaga — полнофункциональная платформа для п
|
||||
|
||||
### 💳 Платежи
|
||||
|
||||
- 🏦 **15 платёжных провайдеров** одновременно
|
||||
- 🏦 **18 платёжных провайдеров** одновременно
|
||||
- 💰 Единый баланс: пополнение любым способом → покупка с баланса
|
||||
- ⚡ Автопокупка подписки после пополнения
|
||||
- 💾 Рекуррентные платежи (сохранённые карты)
|
||||
@@ -123,6 +123,9 @@ Bedolaga — полнофункциональная платформа для п
|
||||
| 💳 | **MulenPay** | Карты | RUB |
|
||||
| 💳 | **RioPay** | Карты | RUB |
|
||||
| 💳 | **SeverPay** | СБП, карты | RUB |
|
||||
| 🤝 | **[PayPear](https://t.me/Paymen1_Manager)** 🔸 | Карты, СБП, SberPay, T-Pay | RUB |
|
||||
| 🤝 | **[RollyPay](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)** 🔸 | СБП, карты, крипто | RUB → USDT |
|
||||
| 🤝 | **[AuraPay](https://aurapay.tech/)** 🔸 | Карты, СБП | RUB |
|
||||
| 📲 | **Tribute** | Telegram-платежи | RUB |
|
||||
|
||||
</div>
|
||||
@@ -157,6 +160,44 @@ Bedolaga — официальный партнёр платёжной систе
|
||||
|
||||
📩 По вопросам: [@wyrz_wata](https://t.me/wyrz_wata)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр PayPear**
|
||||
|
||||
Bedolaga — официальный партнёр платёжной системы **[PayPear](https://paypear.ru)**.<br>
|
||||
Банковские карты, СБП, SberPay и T-Pay — всё через единый API.<br>
|
||||
Подключение по **спец. условиям** через кодовое слово **`БЕДОЛАГА`**
|
||||
|
||||
📩 Менеджер: [@Paymen1_Manager](https://t.me/Paymen1_Manager)
|
||||
|
||||
</td>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр RollyPay**
|
||||
|
||||
Bedolaga — официальный партнёр платёжного шлюза **[RollyPay](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)**.<br>
|
||||
СБП (от 5%), банковские карты РФ, крипто, вывод в USDT.<br>
|
||||
Универсальная форма оплаты, высокая проходимость, стабильная работа в каскаде.<br>
|
||||
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
|
||||
|
||||
📩 Менеджер: [@rollypay_manager](https://t.me/rollypay_manager) | 🌐 [rollypay.io](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр AuraPay**
|
||||
|
||||
Bedolaga — официальный партнёр платёжной системы **[AuraPay](https://aurapay.tech/)**.<br>
|
||||
Банковские карты и СБП через единый API с быстрой интеграцией.<br>
|
||||
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
|
||||
|
||||
📩 Менеджер: [@kickdownm](https://t.me/kickdownm) | 🌐 [aurapay.tech](https://aurapay.tech/)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -222,7 +263,7 @@ docker compose up -d
|
||||
| | Раздел | Описание |
|
||||
|:---:|:---|:---|
|
||||
| 🚀 | [Быстрый старт](https://docs.bedolagam.ru/getting-started/quickstart) | Развёртывание за 5 минут |
|
||||
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 14 провайдеров, webhook, фискализация |
|
||||
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 18 провайдеров, webhook, фискализация |
|
||||
| 📦 | [Подписки и тарифы](https://docs.bedolagam.ru/bot/subscriptions) | Конфигурация планов и трафика |
|
||||
| 👥 | [Реферальная программа](https://docs.bedolagam.ru/bot/referral-program) | Партнёрка и вывод средств |
|
||||
| 🖥 | [Cabinet](https://docs.bedolagam.ru/cabinet/overview) | Настройка веб-кабинета |
|
||||
|
||||
@@ -854,6 +854,37 @@ async def create_topup(
|
||||
detail='Failed to create RollyPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'aurapay':
|
||||
if not settings.is_aurapay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='AuraPay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_aurapay_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 AuraPay payment',
|
||||
)
|
||||
|
||||
else:
|
||||
# For other payment methods, redirect to bot
|
||||
raise HTTPException(
|
||||
|
||||
@@ -611,6 +611,19 @@ class Settings(BaseSettings):
|
||||
ROLLYPAY_WEBHOOK_PATH: str = '/rollypay-webhook'
|
||||
ROLLYPAY_RETURN_URL: str | None = None
|
||||
|
||||
# AuraPay (aurapay.tech)
|
||||
AURAPAY_ENABLED: bool = False
|
||||
AURAPAY_API_KEY: str | None = None # X-ApiKey header
|
||||
AURAPAY_SHOP_ID: str | None = None # X-ShopId header (UUID)
|
||||
AURAPAY_SECRET_KEY: str | None = None # Secret key #2 for webhook HMAC
|
||||
AURAPAY_DISPLAY_NAME: str = 'AuraPay'
|
||||
AURAPAY_CURRENCY: str = 'RUB'
|
||||
AURAPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
AURAPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
AURAPAY_WEBHOOK_PATH: str = '/aurapay-webhook'
|
||||
AURAPAY_RETURN_URL: str | None = None
|
||||
AURAPAY_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
|
||||
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
|
||||
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
|
||||
CABINET_BUTTON_STYLE: str = ''
|
||||
@@ -2016,6 +2029,21 @@ class Settings(BaseSettings):
|
||||
def get_rollypay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_rollypay_display_name())
|
||||
|
||||
def is_aurapay_enabled(self) -> bool:
|
||||
return (
|
||||
self.AURAPAY_ENABLED
|
||||
and self.AURAPAY_API_KEY is not None
|
||||
and self.AURAPAY_SHOP_ID is not None
|
||||
and self.AURAPAY_SECRET_KEY is not None
|
||||
)
|
||||
|
||||
def get_aurapay_display_name(self) -> str:
|
||||
name = (self.AURAPAY_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'AuraPay'
|
||||
|
||||
def get_aurapay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_aurapay_display_name())
|
||||
|
||||
def is_kassa_ai_sbp_enabled(self) -> bool:
|
||||
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""CRUD операции для платежей AuraPay."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import AuraPayPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_aurapay_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,
|
||||
aurapay_invoice_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> AuraPayPayment:
|
||||
"""Создает запись о платеже AuraPay."""
|
||||
payment = AuraPayPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
aurapay_invoice_id=aurapay_invoice_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж AuraPay', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_aurapay_payment_by_order_id(db: AsyncSession, order_id: str) -> AuraPayPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(AuraPayPayment).where(AuraPayPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_aurapay_payment_by_invoice_id(db: AsyncSession, aurapay_invoice_id: str) -> AuraPayPayment | None:
|
||||
"""Получает платеж по UUID от AuraPay."""
|
||||
result = await db.execute(select(AuraPayPayment).where(AuraPayPayment.aurapay_invoice_id == aurapay_invoice_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_aurapay_payment_by_id(db: AsyncSession, payment_id: int) -> AuraPayPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(AuraPayPayment).where(AuraPayPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_aurapay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> AuraPayPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(AuraPayPayment)
|
||||
.where(AuraPayPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_aurapay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: AuraPayPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
aurapay_invoice_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> AuraPayPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
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 aurapay_invoice_id is not None:
|
||||
payment.aurapay_invoice_id = aurapay_invoice_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлен статус платежа AuraPay',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_aurapay_payments(db: AsyncSession, user_id: int) -> list[AuraPayPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(AuraPayPayment).where(
|
||||
AuraPayPayment.user_id == user_id,
|
||||
AuraPayPayment.status == 'pending',
|
||||
AuraPayPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_aurapay_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[AuraPayPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(AuraPayPayment).where(
|
||||
AuraPayPayment.status == 'pending',
|
||||
AuraPayPayment.is_paid == False,
|
||||
AuraPayPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_aurapay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: AuraPayPayment,
|
||||
transaction_id: int,
|
||||
) -> AuraPayPayment:
|
||||
"""Связывает платеж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -164,6 +164,7 @@ class PaymentMethod(Enum):
|
||||
SEVERPAY = 'severpay'
|
||||
PAYPEAR = 'paypear'
|
||||
ROLLYPAY = 'rollypay'
|
||||
AURAPAY = 'aurapay'
|
||||
MANUAL = 'manual'
|
||||
BALANCE = 'balance'
|
||||
|
||||
@@ -1004,6 +1005,68 @@ class RollyPayPayment(Base):
|
||||
return f'<RollyPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class AuraPayPayment(Base):
|
||||
"""Платежи через AuraPay (aurapay.tech)."""
|
||||
|
||||
__tablename__ = 'aurapay_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
|
||||
aurapay_invoice_id = Column(String(128), unique=True, nullable=True, index=True) # UUID от AuraPay
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_method = Column(String(32), nullable=True)
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='aurapay_payments')
|
||||
transaction = relationship('Transaction', backref='aurapay_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'canceled', 'amount_mismatch']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<AuraPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = 'promo_groups'
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
},
|
||||
'payments': {
|
||||
'title': '💳 Платежные системы',
|
||||
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay, SeverPay и Telegram Stars.',
|
||||
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay, SeverPay, PayPear, RollyPay и Telegram Stars.',
|
||||
'icon': '💳',
|
||||
'categories': (
|
||||
'PAYMENT',
|
||||
@@ -76,6 +76,9 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
'KASSA_AI',
|
||||
'RIOPAY',
|
||||
'SEVERPAY',
|
||||
'PAYPEAR',
|
||||
'ROLLYPAY',
|
||||
'AURAPAY',
|
||||
'MULENPAY',
|
||||
'PAL24',
|
||||
'WATA',
|
||||
@@ -1260,6 +1263,15 @@ def _build_settings_keyboard(
|
||||
elif category_key == 'SEVERPAY':
|
||||
label = texts.t('PAYMENT_SEVERPAY', f'💳 {settings.get_severpay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'severpay')])
|
||||
elif category_key == 'PAYPEAR':
|
||||
label = texts.t('PAYMENT_PAYPEAR', f'💳 {settings.get_paypear_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'paypear')])
|
||||
elif category_key == 'ROLLYPAY':
|
||||
label = texts.t('PAYMENT_ROLLYPAY', f'💳 {settings.get_rollypay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'rollypay')])
|
||||
elif category_key == 'AURAPAY':
|
||||
label = texts.t('PAYMENT_AURAPAY', f'💳 {settings.get_aurapay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'aurapay')])
|
||||
|
||||
if test_payment_buttons:
|
||||
rows.extend(test_payment_buttons)
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Handler for AuraPay balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_aurapay_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating AuraPay payment and sending response.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_aurapay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
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_aurapay_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'\U0001f4b3 Оплатить {amount}\u20bd',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'AURAPAY_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}\u20bd</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info('AuraPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_aurapay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.AURAPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.AURAPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}\u20bd',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}\u20bd',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_aurapay_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_aurapay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start AuraPay top-up process - ask for amount.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='aurapay')
|
||||
|
||||
min_amount = settings.AURAPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.AURAPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_aurapay_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'AURAPAY_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}\u20bd\n'
|
||||
'Максимум: {max_amount}\u20bd',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
@@ -163,6 +163,13 @@ async def route_payment_by_method(
|
||||
await process_rollypay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'aurapay':
|
||||
from .aurapay import process_aurapay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_aurapay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'riopay':
|
||||
from .riopay import process_riopay_payment_amount
|
||||
|
||||
@@ -741,6 +748,10 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(start_rollypay_topup, F.data == 'topup_rollypay')
|
||||
|
||||
from .aurapay import start_aurapay_topup
|
||||
|
||||
dp.callback_query.register(start_aurapay_topup, F.data == 'topup_aurapay')
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
|
||||
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
|
||||
|
||||
@@ -2105,8 +2105,36 @@ async def show_tariff_extend(
|
||||
subscription = None
|
||||
else:
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not subscription or not subscription.tariff_id:
|
||||
await callback.answer('Тариф не найден', show_alert=True)
|
||||
if not subscription:
|
||||
await callback.answer('Подписка не найдена', show_alert=True)
|
||||
return
|
||||
|
||||
if not subscription.tariff_id:
|
||||
# Legacy user without tariff — show tariff selection for upgrade
|
||||
promo_group_id = getattr(db_user, 'promo_group_id', None)
|
||||
tariffs = await get_tariffs_for_user(db, promo_group_id)
|
||||
if not tariffs:
|
||||
await callback.answer('Нет доступных тарифов', show_alert=True)
|
||||
return
|
||||
|
||||
keyboard = []
|
||||
for t in tariffs:
|
||||
if t.is_daily:
|
||||
continue
|
||||
keyboard.append([InlineKeyboardButton(text=f'📦 {t.name}', callback_data=f'tariff_select:{t.id}')])
|
||||
if not keyboard:
|
||||
await callback.answer('Нет доступных тарифов для продления', show_alert=True)
|
||||
return
|
||||
keyboard.append([InlineKeyboardButton(text='◀️ Назад', callback_data='back_to_menu')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
'🔄 <b>Выберите тариф для продления</b>\n\n'
|
||||
'Для продления подписки необходимо выбрать тариф.\n'
|
||||
'Подписка будет обновлена с параметрами выбранного тарифа.',
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
|
||||
@@ -1831,6 +1831,18 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_aurapay_enabled():
|
||||
aurapay_name = settings.get_aurapay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_AURAPAY', f'💳 {aurapay_name}'),
|
||||
callback_data=_build_callback('aurapay'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_support_topup_enabled():
|
||||
keyboard.append(
|
||||
[
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Сервис для работы с API AuraPay (aurapay.tech)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
API_BASE_URL = 'https://app.aurapay.tech'
|
||||
|
||||
|
||||
class AuraPayAPIError(Exception):
|
||||
"""Ошибка API AuraPay."""
|
||||
|
||||
def __init__(self, status_code: int, message: str):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
super().__init__(f'AuraPay API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class AuraPayService:
|
||||
"""Сервис для работы с API AuraPay."""
|
||||
|
||||
def __init__(self):
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
return settings.AURAPAY_API_KEY or ''
|
||||
|
||||
@property
|
||||
def shop_id(self) -> str:
|
||||
return settings.AURAPAY_SHOP_ID or ''
|
||||
|
||||
@property
|
||||
def secret_key(self) -> str:
|
||||
return settings.AURAPAY_SECRET_KEY or ''
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Возвращает переиспользуемую HTTP-сессию."""
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрывает HTTP-сессию."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
def _build_headers(self) -> dict[str, str]:
|
||||
"""Строит заголовки запроса с X-ApiKey и X-ShopId."""
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'X-ApiKey': self.api_key,
|
||||
'X-ShopId': self.shop_id,
|
||||
}
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
*,
|
||||
amount: float,
|
||||
order_id: str,
|
||||
comment: str = '',
|
||||
service: str | None = None,
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
callback_url: str | None = None,
|
||||
custom_fields: str | None = None,
|
||||
lifetime: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Создает инвойс через API AuraPay.
|
||||
POST /invoice/create
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'amount': amount,
|
||||
'order_id': order_id,
|
||||
}
|
||||
|
||||
if comment:
|
||||
payload['comment'] = comment
|
||||
if service:
|
||||
payload['service'] = service
|
||||
if success_url:
|
||||
payload['success_url'] = success_url
|
||||
if fail_url:
|
||||
payload['fail_url'] = fail_url
|
||||
if callback_url:
|
||||
payload['callback_url'] = callback_url
|
||||
if custom_fields:
|
||||
payload['custom_fields'] = custom_fields
|
||||
if lifetime is not None:
|
||||
payload['lifetime'] = lifetime
|
||||
|
||||
logger.info(
|
||||
'AuraPay API create_invoice',
|
||||
order_id=order_id,
|
||||
amount=amount,
|
||||
service=service,
|
||||
)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
f'{API_BASE_URL}/invoice/create',
|
||||
json=payload,
|
||||
headers=self._build_headers(),
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status == 200:
|
||||
logger.info(
|
||||
'AuraPay API invoice created',
|
||||
order_id=order_id,
|
||||
invoice_id=data.get('id'),
|
||||
status=data.get('status'),
|
||||
)
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'AuraPay create_invoice error',
|
||||
status_code=response.status,
|
||||
error_msg=error_msg,
|
||||
response_data=data,
|
||||
)
|
||||
raise AuraPayAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('AuraPay API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def get_invoice_status(
|
||||
self,
|
||||
*,
|
||||
order_id: str | None = None,
|
||||
invoice_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Получает статус инвойса.
|
||||
POST /invoice/status
|
||||
"""
|
||||
if not order_id and not invoice_id:
|
||||
raise ValueError('Either order_id or invoice_id must be provided')
|
||||
|
||||
payload: dict[str, str] = {}
|
||||
if order_id:
|
||||
payload['order_id'] = order_id
|
||||
if invoice_id:
|
||||
payload['id'] = invoice_id
|
||||
|
||||
logger.info('AuraPay get_invoice_status', order_id=order_id, invoice_id=invoice_id)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
f'{API_BASE_URL}/invoice/status',
|
||||
json=payload,
|
||||
headers=self._build_headers(),
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status == 200:
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'AuraPay get_invoice_status error',
|
||||
status_code=response.status,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
raise AuraPayAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('AuraPay API connection error', error=e)
|
||||
raise
|
||||
|
||||
def verify_webhook_signature(self, payload: dict[str, Any], received_signature: str) -> bool:
|
||||
"""Верификация подписи webhook AuraPay через HMAC-SHA256.
|
||||
|
||||
Algorithm: sort JSON keys alphabetically, concatenate all VALUES
|
||||
(converted to str) into one string, HMAC-SHA256 with secret key #2.
|
||||
Header: X-SIGNATURE
|
||||
"""
|
||||
try:
|
||||
if not received_signature:
|
||||
logger.warning('AuraPay webhook: отсутствует X-SIGNATURE')
|
||||
return False
|
||||
|
||||
# Сортируем ключи по алфавиту и конкатенируем значения
|
||||
sorted_keys = sorted(payload.keys())
|
||||
concatenated_values = ''.join(str(payload[key]) for key in sorted_keys)
|
||||
|
||||
expected = hmac.new(
|
||||
self.secret_key.encode('utf-8'),
|
||||
concatenated_values.encode('utf-8'),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(expected, received_signature)
|
||||
except Exception as e:
|
||||
logger.error('AuraPay webhook verify error', error=e)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
aurapay_service = AuraPayService()
|
||||
@@ -146,9 +146,22 @@ class DailySubscriptionService:
|
||||
# Недостаточно средств - приостанавливаем подписку
|
||||
await suspend_daily_subscription_insufficient_balance(db, subscription)
|
||||
|
||||
# Уведомляем пользователя
|
||||
# Уведомляем пользователя (rate-limit: 1 раз в 6 часов)
|
||||
if self._bot:
|
||||
await self._notify_insufficient_balance(user, subscription, daily_price)
|
||||
from app.utils.cache import cache
|
||||
|
||||
cache_key = f'daily_insuf_notify:{subscription.id}'
|
||||
try:
|
||||
already_notified = await cache.get(cache_key)
|
||||
except Exception:
|
||||
already_notified = None
|
||||
|
||||
if not already_notified:
|
||||
await self._notify_insufficient_balance(user, subscription, daily_price)
|
||||
try:
|
||||
await cache.set(cache_key, '1', expire=21600) # 6 hours
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
'Подписка приостановлена: недостаточно средств (баланс: требуется: )',
|
||||
|
||||
@@ -1121,10 +1121,6 @@ class PartnerStatsService:
|
||||
),
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
func.abs(Transaction.amount_kopeks),
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
@@ -1137,12 +1133,8 @@ class PartnerStatsService:
|
||||
and_(
|
||||
Transaction.user_id == User.id,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.DEPOSIT.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
),
|
||||
)
|
||||
.where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
|
||||
@@ -1159,10 +1151,6 @@ class PartnerStatsService:
|
||||
),
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
func.abs(Transaction.amount_kopeks),
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
оставался компактным и импортировал только нужные компоненты.
|
||||
"""
|
||||
|
||||
from .aurapay import AuraPayPaymentMixin
|
||||
from .cloudpayments import CloudPaymentsPaymentMixin
|
||||
from .common import PaymentCommonMixin
|
||||
from .cryptobot import CryptoBotPaymentMixin
|
||||
@@ -24,6 +25,7 @@ from .yookassa import YooKassaPaymentMixin
|
||||
|
||||
|
||||
__all__ = [
|
||||
'AuraPayPaymentMixin',
|
||||
'CloudPaymentsPaymentMixin',
|
||||
'CryptoBotPaymentMixin',
|
||||
'FreekassaPaymentMixin',
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
"""Mixin для интеграции с AuraPay (aurapay.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.aurapay_service import aurapay_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов AuraPay -> internal
|
||||
AURAPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'PENDING': ('pending', False),
|
||||
'PAID': ('success', True),
|
||||
'EXPIRED': ('expired', False),
|
||||
}
|
||||
|
||||
|
||||
class AuraPayPaymentMixin:
|
||||
"""Mixin для работы с платежами AuraPay."""
|
||||
|
||||
async def create_aurapay_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:
|
||||
"""
|
||||
Создает платеж AuraPay.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_aurapay_enabled():
|
||||
logger.error('AuraPay не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.AURAPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'AuraPay: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
AURAPAY_MIN_AMOUNT_KOPEKS=settings.AURAPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.AURAPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'AuraPay: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
AURAPAY_MAX_AMOUNT_KOPEKS=settings.AURAPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'ap{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.AURAPAY_CURRENCY
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
try:
|
||||
# Формируем webhook URL
|
||||
webhook_url = None
|
||||
if settings.WEBHOOK_URL:
|
||||
webhook_url = f'{settings.WEBHOOK_URL.rstrip("/")}{settings.AURAPAY_WEBHOOK_PATH}'
|
||||
|
||||
lifetime = settings.AURAPAY_PAYMENT_LIFETIME_MINUTES
|
||||
|
||||
# Используем API для создания инвойса
|
||||
result = await aurapay_service.create_invoice(
|
||||
amount=amount_rubles,
|
||||
order_id=order_id,
|
||||
comment=description,
|
||||
service=payment_method_type,
|
||||
success_url=return_url or settings.AURAPAY_RETURN_URL,
|
||||
fail_url=return_url or settings.AURAPAY_RETURN_URL,
|
||||
callback_url=webhook_url,
|
||||
custom_fields=f'user_id={user_id}' if user_id else None,
|
||||
lifetime=lifetime,
|
||||
)
|
||||
|
||||
payment_data = result.get('payment_data', {})
|
||||
payment_url = payment_data.get('url') if isinstance(payment_data, dict) else None
|
||||
aurapay_invoice_id = result.get('id')
|
||||
|
||||
if not payment_url:
|
||||
logger.error('AuraPay API не вернул URL платежа', result=result)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'AuraPay API: создан инвойс',
|
||||
order_id=order_id,
|
||||
aurapay_invoice_id=aurapay_invoice_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
# Срок действия из expires_at ответа или lifetime минут по умолчанию
|
||||
expires_at_str = result.get('expires_at')
|
||||
if expires_at_str:
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(expires_at_str)
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
except (ValueError, TypeError):
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
|
||||
else:
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
|
||||
|
||||
# Сохраняем в БД
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
local_payment = await aurapay_crud.create_aurapay_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,
|
||||
aurapay_invoice_id=aurapay_invoice_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'AuraPay: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'aurapay_invoice_id': aurapay_invoice_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('AuraPay: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
async def process_aurapay_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает webhook от AuraPay.
|
||||
|
||||
Подпись проверяется в webserver/payments.py до вызова этого метода.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело webhook (signature проверена в webserver)
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
aurapay_invoice_id = payload.get('id')
|
||||
order_id = payload.get('order_id')
|
||||
aurapay_status = payload.get('status')
|
||||
|
||||
if not aurapay_invoice_id or not aurapay_status:
|
||||
logger.warning('AuraPay webhook: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Определяем is_paid по статусу
|
||||
is_confirmed = aurapay_status == 'PAID'
|
||||
|
||||
# Ищем платеж по order_id (наш) или aurapay_invoice_id
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
payment = None
|
||||
if order_id:
|
||||
payment = await aurapay_crud.get_aurapay_payment_by_order_id(db, str(order_id))
|
||||
if not payment and aurapay_invoice_id:
|
||||
payment = await aurapay_crud.get_aurapay_payment_by_invoice_id(db, aurapay_invoice_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'AuraPay webhook: платеж не найден',
|
||||
order_id=order_id,
|
||||
aurapay_invoice_id=aurapay_invoice_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await aurapay_crud.get_aurapay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('AuraPay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Проверка дублирования (re-check from locked row)
|
||||
if payment.is_paid:
|
||||
logger.info('AuraPay webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = AURAPAY_STATUS_MAP.get(aurapay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
# Если статус PAID, принудительно считаем оплаченным
|
||||
if is_confirmed:
|
||||
is_paid = True
|
||||
internal_status = 'success'
|
||||
|
||||
callback_payload = {
|
||||
'aurapay_invoice_id': aurapay_invoice_id,
|
||||
'order_id': order_id,
|
||||
'status': aurapay_status,
|
||||
'amount': payload.get('amount'),
|
||||
'service': payload.get('service'),
|
||||
'payer_details': payload.get('payer_details'),
|
||||
}
|
||||
|
||||
# Проверка суммы ДО обновления статуса
|
||||
# AuraPay отправляет amount как строку "1250.00"
|
||||
if is_paid:
|
||||
amount_value = payload.get('amount')
|
||||
if amount_value is not None:
|
||||
received_kopeks = round(float(amount_value) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'AuraPay amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await aurapay_crud.update_aurapay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
aurapay_invoice_id=aurapay_invoice_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
# Финализируем платеж если оплачен — без промежуточного commit
|
||||
if is_paid:
|
||||
# Inline field assignments to keep FOR UPDATE lock intact
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.aurapay_invoice_id = aurapay_invoice_id or payment.aurapay_invoice_id
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_aurapay_payment(
|
||||
db, payment, aurapay_invoice_id=aurapay_invoice_id, trigger='webhook'
|
||||
)
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
payment = await aurapay_crud.update_aurapay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
aurapay_invoice_id=aurapay_invoice_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('AuraPay webhook: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_aurapay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
aurapay_invoice_id: str | None,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock must be acquired by the caller before invoking this method.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'AuraPay платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
# Read fresh metadata AFTER lock to avoid stale data
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
# --- Guest purchase flow ---
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=str(aurapay_invoice_id) if aurapay_invoice_id else payment.order_id,
|
||||
provider_name='aurapay',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
# Ensure paid fields are set (idempotent — caller may have already set them)
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для AuraPay', 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 = str(aurapay_invoice_id) if aurapay_invoice_id else 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.AURAPAY,
|
||||
)
|
||||
|
||||
display_name = settings.get_aurapay_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.AURAPAY,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await aurapay_crud.link_aurapay_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('AuraPay платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.AURAPAY,
|
||||
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('Ошибка обработки реферального пополнения AuraPay', 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('Ошибка отправки админ уведомления AuraPay', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'\u2705 <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю AuraPay', 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(
|
||||
'Обработан AuraPay платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_aurapay_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Проверяет статус платежа через API."""
|
||||
try:
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
payment = await aurapay_crud.get_aurapay_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
logger.warning('AuraPay payment not found', order_id=order_id)
|
||||
return None
|
||||
|
||||
if payment.is_paid:
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
# Проверяем через API по aurapay_invoice_id или order_id
|
||||
try:
|
||||
order_data = await aurapay_service.get_invoice_status(
|
||||
order_id=payment.order_id,
|
||||
invoice_id=payment.aurapay_invoice_id,
|
||||
)
|
||||
aurapay_status = order_data.get('status')
|
||||
|
||||
if aurapay_status:
|
||||
status_info = AURAPAY_STATUS_MAP.get(aurapay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
if is_paid:
|
||||
# Проверка суммы — AuraPay возвращает amount как число
|
||||
api_amount = order_data.get('amount')
|
||||
if api_amount is not None:
|
||||
received_kopeks = round(float(api_amount) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'AuraPay amount mismatch (API check)',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await aurapay_crud.update_aurapay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
aurapay_invoice_id=payment.aurapay_invoice_id,
|
||||
callback_payload={
|
||||
'check_source': 'api',
|
||||
'aurapay_order_data': order_data,
|
||||
},
|
||||
)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'amount_mismatch',
|
||||
'is_paid': False,
|
||||
}
|
||||
|
||||
# Acquire FOR UPDATE lock before finalization
|
||||
locked = await aurapay_crud.get_aurapay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('AuraPay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return None
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('AuraPay платеж уже обработан (api_check)', order_id=payment.order_id)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
logger.info('AuraPay payment confirmed via API', order_id=payment.order_id)
|
||||
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.callback_payload = {
|
||||
'check_source': 'api',
|
||||
'aurapay_order_data': order_data,
|
||||
}
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await self._finalize_aurapay_payment(
|
||||
db,
|
||||
payment,
|
||||
aurapay_invoice_id=payment.aurapay_invoice_id,
|
||||
trigger='api_check',
|
||||
)
|
||||
elif internal_status != payment.status:
|
||||
# Обновляем статус если изменился
|
||||
payment = await aurapay_crud.update_aurapay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error checking AuraPay payment status via API', error=e)
|
||||
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': payment.status or 'pending',
|
||||
'is_paid': payment.is_paid,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('AuraPay: ошибка проверки статуса', error=e)
|
||||
return None
|
||||
@@ -169,6 +169,16 @@ def _get_method_defaults() -> dict:
|
||||
{'id': 'crypto', 'name': 'Криптовалюта'},
|
||||
],
|
||||
},
|
||||
'aurapay': {
|
||||
'default_display_name': settings.get_aurapay_display_name(),
|
||||
'is_configured': settings.is_aurapay_enabled(),
|
||||
'default_min': settings.AURAPAY_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.AURAPAY_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +223,7 @@ DEFAULT_METHOD_ORDER = [
|
||||
'severpay',
|
||||
'paypear',
|
||||
'rollypay',
|
||||
'aurapay',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.services.payment import (
|
||||
WataPaymentMixin,
|
||||
YooKassaPaymentMixin,
|
||||
)
|
||||
from app.services.payment.aurapay import AuraPayPaymentMixin
|
||||
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
|
||||
from app.services.payment.freekassa import FreekassaPaymentMixin
|
||||
from app.services.payment.kassa_ai import KassaAiPaymentMixin
|
||||
@@ -369,6 +370,79 @@ async def link_paypear_payment_to_transaction(*args, **kwargs):
|
||||
return await paypear_crud.link_paypear_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
# --- RollyPay CRUD wrappers ---
|
||||
|
||||
|
||||
async def create_rollypay_payment(*args, **kwargs):
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
return await rollypay_crud.create_rollypay_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_rollypay_payment_by_order_id(*args, **kwargs):
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
return await rollypay_crud.get_rollypay_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_rollypay_payment_by_rollypay_id(*args, **kwargs):
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
return await rollypay_crud.get_rollypay_payment_by_rollypay_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_rollypay_payment_by_id(*args, **kwargs):
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
return await rollypay_crud.get_rollypay_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_rollypay_payment_by_id_for_update(*args, **kwargs):
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
return await rollypay_crud.get_rollypay_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_rollypay_payment_status(*args, **kwargs):
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
return await rollypay_crud.update_rollypay_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_rollypay_payment_to_transaction(*args, **kwargs):
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
return await rollypay_crud.link_rollypay_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_aurapay_payment(*args, **kwargs):
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
return await aurapay_crud.create_aurapay_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_aurapay_payment_by_order_id(*args, **kwargs):
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
return await aurapay_crud.get_aurapay_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_aurapay_payment_by_invoice_id(*args, **kwargs):
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
return await aurapay_crud.get_aurapay_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_aurapay_payment_by_id(*args, **kwargs):
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
return await aurapay_crud.get_aurapay_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_aurapay_payment_by_id_for_update(*args, **kwargs):
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
return await aurapay_crud.get_aurapay_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_aurapay_payment_status(*args, **kwargs):
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
return await aurapay_crud.update_aurapay_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_aurapay_payment_to_transaction(*args, **kwargs):
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
return await aurapay_crud.link_aurapay_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] = {
|
||||
@@ -394,6 +468,7 @@ class PaymentService(
|
||||
SeverPayPaymentMixin,
|
||||
PayPearPaymentMixin,
|
||||
RollyPayPaymentMixin,
|
||||
AuraPayPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
|
||||
@@ -835,6 +910,50 @@ class PaymentService(
|
||||
}
|
||||
return None
|
||||
|
||||
# --- RollyPay ---------------------------------------------------------
|
||||
if payment_method == 'rollypay':
|
||||
if not settings.is_rollypay_enabled():
|
||||
logger.warning('RollyPay is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_rollypay_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'], 'rollypay')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('rollypay_payment_id') or result.get('order_id'),
|
||||
'provider': 'rollypay',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- AuraPay ----------------------------------------------------------
|
||||
if payment_method == 'aurapay':
|
||||
if not settings.is_aurapay_enabled():
|
||||
logger.warning('AuraPay is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_aurapay_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'], 'aurapay')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('aurapay_invoice_id') or result.get('order_id'),
|
||||
'provider': 'aurapay',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Telegram Stars ---------------------------------------------------
|
||||
if payment_method == 'telegram_stars':
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
|
||||
@@ -91,6 +91,9 @@ class BotConfigurationService:
|
||||
'KASSA_AI': '💳 KassaAI',
|
||||
'RIOPAY': '💳 RioPay',
|
||||
'SEVERPAY': '💳 SeverPay',
|
||||
'PAYPEAR': '💳 PayPear',
|
||||
'ROLLYPAY': '💳 RollyPay',
|
||||
'AURAPAY': '💳 AuraPay',
|
||||
'YOOKASSA': '🟣 YooKassa',
|
||||
'PLATEGA': '💳 {platega_name}',
|
||||
'TRIBUTE': '🎁 Tribute',
|
||||
@@ -152,6 +155,9 @@ class BotConfigurationService:
|
||||
'FREEKASSA': 'Freekassa: ID магазина, API ключ, секретные слова и вебхуки.',
|
||||
'KASSA_AI': 'KassaAI: отдельная платёжка api.fk.life с СБП, картами и SberPay.',
|
||||
'RIOPAY': 'RioPay: платёжная система api.riopay.online с поддержкой карт и СБП.',
|
||||
'PAYPEAR': 'PayPear: платёжная система api.paypear.ru с поддержкой карт, СБП, SberPay и T-Pay.',
|
||||
'ROLLYPAY': 'RollyPay: платёжный шлюз rollypay.io с СБП, картами и криптовалютой.',
|
||||
'AURAPAY': 'AuraPay: платёжный шлюз aurapay.tech с поддержкой карт и СБП.',
|
||||
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
|
||||
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
|
||||
'PAL24': 'PAL24 / PayPalych подключения и лимиты.',
|
||||
@@ -363,6 +369,9 @@ class BotConfigurationService:
|
||||
'KASSA_AI_': 'KASSA_AI',
|
||||
'RIOPAY_': 'RIOPAY',
|
||||
'SEVERPAY_': 'SEVERPAY',
|
||||
'PAYPEAR_': 'PAYPEAR',
|
||||
'ROLLYPAY_': 'ROLLYPAY',
|
||||
'AURAPAY_': 'AURAPAY',
|
||||
'PLATEGA_': 'PLATEGA',
|
||||
'MULENPAY_': 'MULENPAY',
|
||||
'PAL24_': 'PAL24',
|
||||
|
||||
@@ -1340,6 +1340,55 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# AuraPay webhook
|
||||
if settings.is_aurapay_enabled():
|
||||
|
||||
@router.get(settings.AURAPAY_WEBHOOK_PATH)
|
||||
async def aurapay_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'aurapay_webhook',
|
||||
'enabled': settings.is_aurapay_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.AURAPAY_WEBHOOK_PATH)
|
||||
async def aurapay_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('AuraPay webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Подпись через заголовок X-SIGNATURE
|
||||
received_signature = request.headers.get('X-SIGNATURE', '')
|
||||
|
||||
from app.services.aurapay_service import aurapay_service
|
||||
|
||||
if not aurapay_service.verify_webhook_signature(payload, received_signature):
|
||||
logger.warning('AuraPay webhook: invalid signature')
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_aurapay_webhook',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'AuraPay webhook processing failed',
|
||||
data=payload.get('id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('AuraPay webhook processing error', error=e)
|
||||
# Always return 200 — AuraPay retries on non-200 (5 attempts)
|
||||
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
if routes_registered:
|
||||
|
||||
@router.get('/health/payment-webhooks')
|
||||
@@ -1362,6 +1411,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
'severpay_enabled': settings.is_severpay_enabled(),
|
||||
'paypear_enabled': settings.is_paypear_enabled(),
|
||||
'rollypay_enabled': settings.is_rollypay_enabled(),
|
||||
'aurapay_enabled': settings.is_aurapay_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""create aurapay_payments table
|
||||
|
||||
Revision ID: 0060
|
||||
Revises: 0059
|
||||
Create Date: 2026-04-15
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0060'
|
||||
down_revision: Union[str, None] = '0059'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'aurapay_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('aurapay_invoice_id', sa.String(128), unique=True, nullable=True, index=True),
|
||||
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
|
||||
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
|
||||
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||
sa.Column('payment_url', sa.Text(), nullable=True),
|
||||
sa.Column('payment_method', sa.String(32), nullable=True),
|
||||
sa.Column('metadata_json', sa.JSON(), nullable=True),
|
||||
sa.Column('callback_payload', sa.JSON(), nullable=True),
|
||||
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column(
|
||||
'transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('aurapay_payments')
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.48.0"
|
||||
version = "3.49.0"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
|
||||
Reference in New Issue
Block a user