Compare commits

...

13 Commits

Author SHA1 Message Date
Egor 1b94d9e700 Merge pull request #2890 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.49.0
2026-04-18 04:00:56 +03:00
github-actions[bot] 39a7c92cd4 chore(main): release 3.49.0 2026-04-18 00:58:34 +00:00
Egor 81ebec676c Merge pull request #2889 from BEDOLAGA-DEV/dev
Dev
2026-04-18 03:58:10 +03:00
Fringg 25ea5c60fd docs: add AuraPay to README with partner block 2026-04-18 03:56:48 +03:00
Fringg 29877fc93b fix: handle edge case when all tariffs are daily in legacy renewal 2026-04-18 01:09:53 +03:00
Fringg 5986c00fab fix: redirect legacy users without tariff to tariff selection on renewal
Users created before tariffs were introduced (tariff_id=NULL) got
"Тариф не найден" when pressing "Продлить подписку". Now they see
a tariff selection list instead, allowing them to pick a tariff
and renew with proper parameters (traffic, devices, etc).
2026-04-18 01:05:48 +03:00
Fringg ecc4a6147d fix: rate-limit daily subscription insufficient balance notifications to 6 hours
Users with daily subscriptions and low balance were getting
"Подписка приостановлена" notification every 30 minutes (on each
charge cycle). Now rate-limited via Redis cache to max 1 notification
per 6 hours per subscription.
2026-04-18 00:59:09 +03:00
Fringg 16bc1d4198 fix: align campaign top registrations revenue with period comparison
Top registrations list was summing DEPOSIT + SUBSCRIPTION_PAYMENT
transactions (total user spending), while period comparison revenue
only counted DEPOSIT with real payment methods (actual money paid in).

Now both use the same calculation: only DEPOSIT transactions with
real payment methods. This fixes the discrepancy where a user showed
500₽ in the list but total revenue was 250₽.
2026-04-18 00:50:23 +03:00
Fringg 0f814be1b7 fix: add missing RollyPay CRUD wrappers and guest payment flow
RollyPay was missing:
- 7 CRUD wrapper functions in payment_service.py (create, get, update, link)
- Guest payment block in create_guest_payment()

Both were present for PayPear and AuraPay but omitted for RollyPay.
2026-04-18 00:23:38 +03:00
Fringg 97179360c0 feat: integrate AuraPay payment provider
Full integration following PayPear/RollyPay patterns:
- API client with X-ApiKey + X-ShopId auth, HMAC-SHA256 webhook verification
  (sorted keys, concatenated values, secret key #2)
- AuraPayPaymentMixin with create, webhook, finalize (all side effects), status check
- CRUD, model, migration (0060), PaymentMethod.AURAPAY enum
- Webhook endpoint, cabinet topup, bot handler with sub-options (card, sbp)
- Admin panel: AURAPAY category in bot_configuration + system_settings_service
- Config: AURAPAY_ENABLED, AURAPAY_API_KEY, AURAPAY_SHOP_ID, AURAPAY_SECRET_KEY
2026-04-18 00:02:49 +03:00
Fringg 2aa5927433 fix: register PayPear and RollyPay in admin panel settings
- bot_configuration.py: added PAYPEAR/ROLLYPAY to payment categories
  and test payment buttons
- system_settings_service.py: added category titles, descriptions,
  and prefix mappings for PAYPEAR_* and ROLLYPAY_* settings
2026-04-17 23:44:35 +03:00
Egor 1c696c69e3 Merge pull request #2887 from BEDOLAGA-DEV/dev
docs: add PayPear and RollyPay to README with partner blocks
2026-04-16 06:26:19 +03:00
Fringg b531959982 docs: add PayPear and RollyPay to README with partner blocks 2026-04-16 06:25:33 +03:00
25 changed files with 1787 additions and 24 deletions
+18
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.48.0"
".": "3.49.0"
}
+24
View File
@@ -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
View File
@@ -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
+43 -2
View File
@@ -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) | Настройка веб-кабинета |
+31
View File
@@ -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(
+28
View File
@@ -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()
+157
View File
@@ -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
+63
View File
@@ -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'
+13 -1
View File
@@ -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)
+247
View File
@@ -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,
)
+11
View File
@@ -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_'))
+30 -2
View File
@@ -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)
+12
View File
@@ -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(
[
+216
View File
@@ -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()
+15 -2
View File
@@ -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(
'Подписка приостановлена: недостаточно средств (баланс: требуется: )',
+2 -14
View File
@@ -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,
)
),
+2
View File
@@ -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',
+636
View File
@@ -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',
]
+119
View File
@@ -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:
+9
View File
@@ -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',
+50
View File
@@ -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
View File
@@ -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' }