Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b94d9e700 | |||
| 39a7c92cd4 | |||
| 81ebec676c | |||
| 25ea5c60fd | |||
| 29877fc93b | |||
| 5986c00fab | |||
| ecc4a6147d | |||
| 16bc1d4198 | |||
| 0f814be1b7 | |||
| 97179360c0 | |||
| 2aa5927433 | |||
| 1c696c69e3 | |||
| b531959982 | |||
| ead0fc99d3 | |||
| c09ae7436c | |||
| 1ea76575ce | |||
| 25447edc9e | |||
| a59858227f | |||
| ccc2f4efec | |||
| a18f6caa9b | |||
| 92eaf45311 | |||
| 61cf495fc5 | |||
| 0c545490b6 | |||
| 2d5afe5d75 | |||
| 4fe67a9c74 | |||
| 30a1a31978 | |||
| c2b68e1afa | |||
| cea7260a85 | |||
| e3c0caabcf | |||
| aeaa4f8e0d | |||
| e226ac8637 | |||
| 85403da528 | |||
| bdd873382c | |||
| 9d750d9eb0 | |||
| 87b83f59c6 | |||
| 8ff6f99229 | |||
| 94da3c35b0 | |||
| d35a8bb74c | |||
| c635d88764 |
@@ -684,6 +684,70 @@ RIOPAY_WEBHOOK_PATH=/riopay-webhook
|
||||
RIOPAY_SUCCESS_URL=
|
||||
RIOPAY_FAIL_URL=
|
||||
|
||||
# ===== SEVERPAY (severpay.io) =====
|
||||
SEVERPAY_ENABLED=false
|
||||
# Merchant ID
|
||||
SEVERPAY_MID=
|
||||
# Секретный токен для HMAC-SHA256
|
||||
SEVERPAY_TOKEN=
|
||||
SEVERPAY_DISPLAY_NAME=SeverPay
|
||||
SEVERPAY_CURRENCY=RUB
|
||||
SEVERPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
SEVERPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
SEVERPAY_WEBHOOK_PATH=/severpay-webhook
|
||||
# URL возврата после оплаты
|
||||
# SEVERPAY_RETURN_URL=
|
||||
# Время жизни платежа в минутах (30-4320)
|
||||
SEVERPAY_LIFETIME=1440
|
||||
|
||||
# ===== PAYPEAR (api.paypear.ru) =====
|
||||
PAYPEAR_ENABLED=false
|
||||
# Shop ID для HTTP Basic Auth
|
||||
PAYPEAR_SHOP_ID=
|
||||
# Secret Key для HTTP Basic Auth
|
||||
PAYPEAR_SECRET_KEY=
|
||||
PAYPEAR_DISPLAY_NAME=PayPear
|
||||
PAYPEAR_CURRENCY=RUB
|
||||
PAYPEAR_MIN_AMOUNT_KOPEKS=10000
|
||||
PAYPEAR_MAX_AMOUNT_KOPEKS=10000000
|
||||
PAYPEAR_WEBHOOK_PATH=/paypear-webhook
|
||||
# URL возврата после оплаты
|
||||
# PAYPEAR_RETURN_URL=
|
||||
# Время жизни платежа в минутах
|
||||
PAYPEAR_PAYMENT_LIFETIME_MINUTES=60
|
||||
|
||||
# ===== ROLLYPAY (rollypay.io) =====
|
||||
ROLLYPAY_ENABLED=false
|
||||
# API ключ (X-API-Key header)
|
||||
ROLLYPAY_API_KEY=
|
||||
# Секрет для HMAC-SHA256 верификации вебхуков
|
||||
ROLLYPAY_SIGNING_SECRET=
|
||||
ROLLYPAY_DISPLAY_NAME=RollyPay
|
||||
ROLLYPAY_CURRENCY=RUB
|
||||
ROLLYPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
ROLLYPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 822 KiB |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.47.0"
|
||||
".": "3.49.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# 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)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* integrate PayPear payment provider ([a18f6ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a18f6caa9bd9c08511c464e6141fb8aa614135b0))
|
||||
* integrate RollyPay payment provider (SBP via USDT) ([ccc2f4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ccc2f4efecf0a3c1a943971c81a9a7d1985ae14a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* increase nalogo receipt queue retry window to 12 hours ([92eaf45](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/92eaf4531162e5625b938bafc43eb98abe2632e2))
|
||||
* low balance alerts disabled by default, add quiet hours, expiry filter, top-up button ([2d5afe5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2d5afe5d75ff65f4d167a853e078943d518a881f))
|
||||
* show menu buttons for limited (traffic exhausted) subscriptions ([0c54549](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c545490b61baec930f10adc86652a7e5cf5d378))
|
||||
* show menu buttons for limited subscriptions in back-to-menu paths ([61cf495](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61cf495fc5919e699aba7231f49222722da044b4))
|
||||
* support payment_method selection for RollyPay (sbp/card/crypto) ([a598582](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a59858227f31bd53d0ac54d693288ad52e447687))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add SEVERPAY, PAYPEAR, ROLLYPAY to .env.example ([25447ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25447edc9eab7c3a76ed94be52c1b341917a40c2))
|
||||
|
||||
## [3.47.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.46.1...v3.47.0) (2026-04-15)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.47.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) | Настройка веб-кабинета |
|
||||
|
||||
@@ -794,6 +794,97 @@ async def create_topup(
|
||||
detail='Failed to create SeverPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'paypear':
|
||||
if not settings.is_paypear_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='PayPear payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_paypear_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,
|
||||
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 PayPear payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'rollypay':
|
||||
if not settings.is_rollypay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='RollyPay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_rollypay_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 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,7 +28,7 @@ class NotificationSettingsResponse(BaseModel):
|
||||
subscription_expiry_days: int = 3
|
||||
traffic_warning_enabled: bool = True
|
||||
traffic_warning_percent: int = 80
|
||||
balance_low_enabled: bool = True
|
||||
balance_low_enabled: bool = False
|
||||
balance_low_threshold: int = 100 # kopeks
|
||||
news_enabled: bool = True
|
||||
promo_offers_enabled: bool = True
|
||||
@@ -60,7 +60,7 @@ def _get_notification_settings(user: User) -> dict[str, Any]:
|
||||
'subscription_expiry_days': settings_data.get('subscription_expiry_days', 3),
|
||||
'traffic_warning_enabled': settings_data.get('traffic_warning_enabled', True),
|
||||
'traffic_warning_percent': settings_data.get('traffic_warning_percent', 80),
|
||||
'balance_low_enabled': settings_data.get('balance_low_enabled', True),
|
||||
'balance_low_enabled': settings_data.get('balance_low_enabled', False),
|
||||
'balance_low_threshold': settings_data.get('balance_low_threshold', 100),
|
||||
'news_enabled': settings_data.get('news_enabled', True),
|
||||
'promo_offers_enabled': settings_data.get('promo_offers_enabled', True),
|
||||
|
||||
+74
-2
@@ -68,9 +68,9 @@ class Settings(BaseSettings):
|
||||
ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID: int | None = None # Партнёрки, выводы, админ-действия
|
||||
|
||||
# Настройки очереди чеков NaloGO
|
||||
NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды)
|
||||
NALOGO_QUEUE_CHECK_INTERVAL: int = 600 # Интервал проверки очереди (секунды, 10 мин)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS: int = 10 # Максимум попыток отправки чека
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS: int = 72 # Максимум попыток отправки чека (72 × 10мин = 12 часов)
|
||||
|
||||
ADMIN_REPORTS_ENABLED: bool = False
|
||||
ADMIN_REPORTS_CHAT_ID: str | None = None
|
||||
@@ -324,6 +324,7 @@ class Settings(BaseSettings):
|
||||
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS: int = 20000
|
||||
|
||||
MONITORING_INTERVAL: int = 60
|
||||
LOW_BALANCE_ALERT_EXPIRY_DAYS: int = 3 # Only alert when subscription expires within N days
|
||||
INACTIVE_USER_DELETE_MONTHS: int = 3
|
||||
|
||||
MAINTENANCE_MODE: bool = False
|
||||
@@ -587,6 +588,42 @@ class Settings(BaseSettings):
|
||||
SEVERPAY_RETURN_URL: str | None = None
|
||||
SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320
|
||||
|
||||
# PayPear (paypear.ru)
|
||||
PAYPEAR_ENABLED: bool = False
|
||||
PAYPEAR_SHOP_ID: str | None = None
|
||||
PAYPEAR_SECRET_KEY: str | None = None
|
||||
PAYPEAR_DISPLAY_NAME: str = 'PayPear'
|
||||
PAYPEAR_CURRENCY: str = 'RUB'
|
||||
PAYPEAR_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
PAYPEAR_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
PAYPEAR_WEBHOOK_PATH: str = '/paypear-webhook'
|
||||
PAYPEAR_RETURN_URL: str | None = None
|
||||
PAYPEAR_PAYMENT_METHOD: str = 'sbp' # bank_card, sbp, sberpay, tpay
|
||||
|
||||
# RollyPay (rollypay.io)
|
||||
ROLLYPAY_ENABLED: bool = False
|
||||
ROLLYPAY_API_KEY: str | None = None # X-API-Key header
|
||||
ROLLYPAY_SIGNING_SECRET: str | None = None # HMAC webhook verification
|
||||
ROLLYPAY_DISPLAY_NAME: str = 'RollyPay'
|
||||
ROLLYPAY_CURRENCY: str = 'RUB'
|
||||
ROLLYPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
ROLLYPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
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 = ''
|
||||
@@ -1972,6 +2009,41 @@ class Settings(BaseSettings):
|
||||
def get_severpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_severpay_display_name())
|
||||
|
||||
def is_paypear_enabled(self) -> bool:
|
||||
return self.PAYPEAR_ENABLED and self.PAYPEAR_SHOP_ID is not None and self.PAYPEAR_SECRET_KEY is not None
|
||||
|
||||
def get_paypear_display_name(self) -> str:
|
||||
name = (self.PAYPEAR_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'PayPear'
|
||||
|
||||
def get_paypear_display_name_html(self) -> str:
|
||||
return html.escape(self.get_paypear_display_name())
|
||||
|
||||
def is_rollypay_enabled(self) -> bool:
|
||||
return self.ROLLYPAY_ENABLED and self.ROLLYPAY_API_KEY is not None and self.ROLLYPAY_SIGNING_SECRET is not None
|
||||
|
||||
def get_rollypay_display_name(self) -> str:
|
||||
name = (self.ROLLYPAY_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'RollyPay'
|
||||
|
||||
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
|
||||
@@ -0,0 +1,157 @@
|
||||
"""CRUD операции для платежей PayPear."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PayPearPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_paypear_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,
|
||||
paypear_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> PayPearPayment:
|
||||
"""Создает запись о платеже PayPear."""
|
||||
payment = PayPearPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
paypear_id=paypear_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('Создан платеж PayPear', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_paypear_payment_by_order_id(db: AsyncSession, order_id: str) -> PayPearPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(PayPearPayment).where(PayPearPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_paypear_payment_by_paypear_id(db: AsyncSession, paypear_id: str) -> PayPearPayment | None:
|
||||
"""Получает платеж по ID от PayPear."""
|
||||
result = await db.execute(select(PayPearPayment).where(PayPearPayment.paypear_id == paypear_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_paypear_payment_by_id(db: AsyncSession, payment_id: int) -> PayPearPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(PayPearPayment).where(PayPearPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_paypear_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> PayPearPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(PayPearPayment)
|
||||
.where(PayPearPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_paypear_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: PayPearPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
paypear_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> PayPearPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
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 paypear_id is not None:
|
||||
payment.paypear_id = paypear_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(
|
||||
'Обновлен статус платежа PayPear',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_paypear_payments(db: AsyncSession, user_id: int) -> list[PayPearPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(PayPearPayment).where(
|
||||
PayPearPayment.user_id == user_id,
|
||||
PayPearPayment.status == 'pending',
|
||||
PayPearPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_paypear_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[PayPearPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(PayPearPayment).where(
|
||||
PayPearPayment.status == 'pending',
|
||||
PayPearPayment.is_paid == False,
|
||||
PayPearPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_paypear_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: PayPearPayment,
|
||||
transaction_id: int,
|
||||
) -> PayPearPayment:
|
||||
"""Связывает платеж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -0,0 +1,157 @@
|
||||
"""CRUD операции для платежей RollyPay."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import RollyPayPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_rollypay_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,
|
||||
rollypay_payment_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> RollyPayPayment:
|
||||
"""Создает запись о платеже RollyPay."""
|
||||
payment = RollyPayPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
rollypay_payment_id=rollypay_payment_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж RollyPay', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_rollypay_payment_by_order_id(db: AsyncSession, order_id: str) -> RollyPayPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(RollyPayPayment).where(RollyPayPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_rollypay_payment_by_rollypay_id(db: AsyncSession, rollypay_payment_id: str) -> RollyPayPayment | None:
|
||||
"""Получает платеж по ID от RollyPay."""
|
||||
result = await db.execute(select(RollyPayPayment).where(RollyPayPayment.rollypay_payment_id == rollypay_payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_rollypay_payment_by_id(db: AsyncSession, payment_id: int) -> RollyPayPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(RollyPayPayment).where(RollyPayPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_rollypay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> RollyPayPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(RollyPayPayment)
|
||||
.where(RollyPayPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_rollypay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: RollyPayPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
rollypay_payment_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> RollyPayPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
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 rollypay_payment_id is not None:
|
||||
payment.rollypay_payment_id = rollypay_payment_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлен статус платежа RollyPay',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_rollypay_payments(db: AsyncSession, user_id: int) -> list[RollyPayPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(RollyPayPayment).where(
|
||||
RollyPayPayment.user_id == user_id,
|
||||
RollyPayPayment.status == 'pending',
|
||||
RollyPayPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_rollypay_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[RollyPayPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(RollyPayPayment).where(
|
||||
RollyPayPayment.status == 'pending',
|
||||
RollyPayPayment.is_paid == False,
|
||||
RollyPayPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_rollypay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: RollyPayPayment,
|
||||
transaction_id: int,
|
||||
) -> RollyPayPayment:
|
||||
"""Связывает платеж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -162,6 +162,9 @@ class PaymentMethod(Enum):
|
||||
KASSA_AI = 'kassa_ai'
|
||||
RIOPAY = 'riopay'
|
||||
SEVERPAY = 'severpay'
|
||||
PAYPEAR = 'paypear'
|
||||
ROLLYPAY = 'rollypay'
|
||||
AURAPAY = 'aurapay'
|
||||
MANUAL = 'manual'
|
||||
BALANCE = 'balance'
|
||||
|
||||
@@ -878,6 +881,192 @@ class SeverPayPayment(Base):
|
||||
return f'<SeverPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class PayPearPayment(Base):
|
||||
"""Платежи через PayPear (paypear.ru)."""
|
||||
|
||||
__tablename__ = 'paypear_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
|
||||
paypear_id = Column(String(64), unique=True, nullable=True, index=True) # ID от PayPear
|
||||
|
||||
# Суммы
|
||||
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='paypear_payments')
|
||||
transaction = relationship('Transaction', backref='paypear_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'<PayPearPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class RollyPayPayment(Base):
|
||||
"""Платежи через RollyPay (rollypay.io)."""
|
||||
|
||||
__tablename__ = 'rollypay_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
|
||||
rollypay_payment_id = Column(String(128), unique=True, nullable=True, index=True) # pay_uuid от RollyPay
|
||||
|
||||
# Суммы
|
||||
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='rollypay_payments')
|
||||
transaction = relationship('Transaction', backref='rollypay_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', 'chargeback', 'amount_mismatch']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
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,
|
||||
)
|
||||
@@ -149,6 +149,27 @@ async def route_payment_by_method(
|
||||
await process_severpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'paypear':
|
||||
from .paypear import process_paypear_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_paypear_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'rollypay':
|
||||
from .rollypay import process_rollypay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
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
|
||||
|
||||
@@ -719,6 +740,18 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(start_severpay_topup, F.data == 'topup_severpay')
|
||||
|
||||
from .paypear import start_paypear_topup
|
||||
|
||||
dp.callback_query.register(start_paypear_topup, F.data == 'topup_paypear')
|
||||
|
||||
from .rollypay import start_rollypay_topup
|
||||
|
||||
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_'))
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Handler for PayPear 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_paypear_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating PayPear 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_paypear_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_paypear_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(
|
||||
'PAYPEAR_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('PayPear payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_paypear_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.PAYPEAR_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.PAYPEAR_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_paypear_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_paypear_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start PayPear 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='paypear')
|
||||
|
||||
min_amount = settings.PAYPEAR_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.PAYPEAR_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_paypear_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(
|
||||
'PAYPEAR_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,
|
||||
)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Handler for RollyPay 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_rollypay_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating RollyPay 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_rollypay_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_rollypay_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(
|
||||
'ROLLYPAY_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('RollyPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_rollypay_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.ROLLYPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.ROLLYPAY_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_rollypay_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_rollypay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start RollyPay 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='rollypay')
|
||||
|
||||
min_amount = settings.ROLLYPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.ROLLYPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_rollypay_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(
|
||||
'ROLLYPAY_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,
|
||||
)
|
||||
@@ -169,7 +169,9 @@ async def show_main_menu(
|
||||
await db.commit()
|
||||
|
||||
# Multi-tariff aware: check if user has ANY active subscription
|
||||
has_active_subscription = any(sub.is_active for sub in (getattr(db_user, 'subscriptions', None) or []))
|
||||
# 'limited' (traffic exhausted) subscriptions are still active for UI purposes
|
||||
_subs = getattr(db_user, 'subscriptions', None) or []
|
||||
has_active_subscription = any(sub.is_active or getattr(sub, 'actual_status', None) == 'limited' for sub in _subs)
|
||||
subscription_is_active = has_active_subscription
|
||||
|
||||
menu_text = await get_main_menu_text(db_user, texts, db)
|
||||
@@ -1013,7 +1015,9 @@ async def handle_back_to_menu(callback: types.CallbackQuery, state: FSMContext,
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Multi-tariff aware: check if user has ANY active subscription
|
||||
has_active_subscription = any(sub.is_active for sub in (getattr(db_user, 'subscriptions', None) or []))
|
||||
# 'limited' (traffic exhausted) subscriptions are still active for UI purposes
|
||||
_subs = getattr(db_user, 'subscriptions', None) or []
|
||||
has_active_subscription = any(sub.is_active or getattr(sub, 'actual_status', None) == 'limited' for sub in _subs)
|
||||
subscription_is_active = has_active_subscription
|
||||
|
||||
menu_text = await get_main_menu_text(db_user, texts, db)
|
||||
|
||||
@@ -344,8 +344,9 @@ def _calculate_subscription_flags(subscription):
|
||||
return False, False
|
||||
|
||||
actual_status = getattr(subscription, 'actual_status', None)
|
||||
has_active_subscription = actual_status in {'active', 'trial'}
|
||||
subscription_is_active = bool(getattr(subscription, 'is_active', False))
|
||||
# 'limited' subscriptions are still active (traffic exhausted, but subscription not expired)
|
||||
has_active_subscription = actual_status in {'active', 'trial', 'limited'}
|
||||
subscription_is_active = bool(getattr(subscription, 'is_active', False)) or actual_status == 'limited'
|
||||
|
||||
return has_active_subscription, subscription_is_active
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1807,6 +1807,42 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_paypear_enabled():
|
||||
paypear_name = settings.get_paypear_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_PAYPEAR', f'💳 Оплата ({paypear_name})'),
|
||||
callback_data=_build_callback('paypear'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_rollypay_enabled():
|
||||
rollypay_name = settings.get_rollypay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ROLLYPAY', f'💳 {rollypay_name}'),
|
||||
callback_data=_build_callback('rollypay'),
|
||||
)
|
||||
]
|
||||
)
|
||||
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(
|
||||
'Подписка приостановлена: недостаточно средств (баланс: требуется: )',
|
||||
|
||||
@@ -2075,23 +2075,43 @@ class MonitoringService:
|
||||
logger.error('Error checking traffic warnings', error=error)
|
||||
|
||||
async def _check_low_balance_alerts(self, db: AsyncSession):
|
||||
"""Check users with autopay enabled who have low balance and notify them."""
|
||||
"""Check users with autopay enabled who have low balance and notify them.
|
||||
|
||||
Guards:
|
||||
- Disabled by default; users opt-in via cabinet notification settings
|
||||
- Only alerts when subscription expires within LOW_BALANCE_ALERT_EXPIRY_DAYS (default 3)
|
||||
- Quiet hours: skips sending between 22:00 and 09:00 server time
|
||||
- Rate-limited: max 1 alert per 24 hours per user
|
||||
"""
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
try:
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import Subscription, User
|
||||
from app.utils.notification_prefs import get_balance_low_threshold, is_balance_low_enabled
|
||||
|
||||
# Only check users with active autopay subscriptions — low balance matters for them
|
||||
# Quiet hours: don't disturb users at night (22:00-09:00 UTC)
|
||||
current_hour = datetime.now(UTC).hour
|
||||
if current_hour >= 22 or current_hour < 9:
|
||||
return
|
||||
|
||||
# Only alert for subscriptions expiring soon (default 3 days)
|
||||
expiry_days = getattr(settings, 'LOW_BALANCE_ALERT_EXPIRY_DAYS', 3)
|
||||
expiry_threshold = datetime.now(UTC) + timedelta(days=expiry_days)
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.join(Subscription, Subscription.user_id == User.id)
|
||||
.where(
|
||||
Subscription.status.in_(['active', 'trial']),
|
||||
Subscription.autopay_enabled.is_(True),
|
||||
Subscription.end_date.isnot(None),
|
||||
Subscription.end_date <= expiry_threshold,
|
||||
User.telegram_id.isnot(None),
|
||||
)
|
||||
.distinct()
|
||||
@@ -2134,10 +2154,28 @@ class MonitoringService:
|
||||
balance=f'{balance_rub:.0f}',
|
||||
threshold=f'{threshold_rub:.0f}',
|
||||
)
|
||||
|
||||
# Build inline keyboard with cabinet top-up button
|
||||
keyboard = None
|
||||
miniapp_url = settings.get_main_menu_miniapp_url()
|
||||
if miniapp_url:
|
||||
topup_label = texts.get('LOW_BALANCE_TOPUP_BUTTON', '💳 Пополнить баланс')
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=topup_label,
|
||||
web_app=WebAppInfo(url=miniapp_url),
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
# Mark as sent for 24 hours
|
||||
try:
|
||||
|
||||
@@ -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
|
||||
@@ -12,8 +13,10 @@ from .heleket import HeleketPaymentMixin
|
||||
from .kassa_ai import KassaAiPaymentMixin
|
||||
from .mulenpay import MulenPayPaymentMixin
|
||||
from .pal24 import Pal24PaymentMixin
|
||||
from .paypear import PayPearPaymentMixin
|
||||
from .platega import PlategaPaymentMixin
|
||||
from .riopay import RioPayPaymentMixin
|
||||
from .rollypay import RollyPayPaymentMixin
|
||||
from .severpay import SeverPayPaymentMixin
|
||||
from .stars import TelegramStarsMixin
|
||||
from .tribute import TributePaymentMixin
|
||||
@@ -22,6 +25,7 @@ from .yookassa import YooKassaPaymentMixin
|
||||
|
||||
|
||||
__all__ = [
|
||||
'AuraPayPaymentMixin',
|
||||
'CloudPaymentsPaymentMixin',
|
||||
'CryptoBotPaymentMixin',
|
||||
'FreekassaPaymentMixin',
|
||||
@@ -29,9 +33,11 @@ __all__ = [
|
||||
'KassaAiPaymentMixin',
|
||||
'MulenPayPaymentMixin',
|
||||
'Pal24PaymentMixin',
|
||||
'PayPearPaymentMixin',
|
||||
'PaymentCommonMixin',
|
||||
'PlategaPaymentMixin',
|
||||
'RioPayPaymentMixin',
|
||||
'RollyPayPaymentMixin',
|
||||
'SeverPayPaymentMixin',
|
||||
'TelegramStarsMixin',
|
||||
'TributePaymentMixin',
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,624 @@
|
||||
"""Mixin для интеграции с PayPear (paypear.ru)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.paypear_service import paypear_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов PayPear -> internal
|
||||
PAYPEAR_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'NEW': ('pending', False),
|
||||
'PROCESS': ('processing', False),
|
||||
'CONFIRMED': ('success', True),
|
||||
'CANCELED': ('canceled', False),
|
||||
'REFUNDED': ('refunded', False),
|
||||
'EXPIRED': ('expired', False),
|
||||
}
|
||||
|
||||
|
||||
class PayPearPaymentMixin:
|
||||
"""Mixin для работы с платежами PayPear."""
|
||||
|
||||
async def create_paypear_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж PayPear.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_paypear_enabled():
|
||||
logger.error('PayPear не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.PAYPEAR_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'PayPear: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
PAYPEAR_MIN_AMOUNT_KOPEKS=settings.PAYPEAR_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.PAYPEAR_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'PayPear: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
PAYPEAR_MAX_AMOUNT_KOPEKS=settings.PAYPEAR_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'pp{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.PAYPEAR_CURRENCY
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
try:
|
||||
payment_method_type = settings.PAYPEAR_PAYMENT_METHOD
|
||||
|
||||
# Используем API для создания платежа
|
||||
result = await paypear_service.create_payment(
|
||||
order_id=order_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
payment_method_type=payment_method_type,
|
||||
description=description,
|
||||
return_url=return_url or settings.PAYPEAR_RETURN_URL,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
confirmation = result.get('confirmation', {})
|
||||
payment_url = confirmation.get('url') if isinstance(confirmation, dict) else None
|
||||
paypear_id = result.get('id')
|
||||
|
||||
if not payment_url:
|
||||
logger.error('PayPear API не вернул URL платежа', result=result)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'PayPear API: создан платеж',
|
||||
order_id=order_id,
|
||||
paypear_id=paypear_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
# Срок действия — 30 минут по умолчанию
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=30)
|
||||
|
||||
# Сохраняем в БД
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
local_payment = await paypear_crud.create_paypear_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,
|
||||
paypear_id=paypear_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'PayPear: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'paypear_id': paypear_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('PayPear: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
async def process_paypear_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает webhook от PayPear.
|
||||
|
||||
Подпись проверяется в webserver/payments.py до вызова этого метода.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело webhook (signature проверена в webserver)
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
event = payload.get('event')
|
||||
obj = payload.get('object', {})
|
||||
paypear_id = obj.get('id')
|
||||
order_id = obj.get('order_id')
|
||||
paypear_status = obj.get('status')
|
||||
|
||||
if not paypear_id or not paypear_status:
|
||||
logger.warning('PayPear webhook: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Определяем is_paid по event
|
||||
is_confirmed = event == 'payment.confirmed'
|
||||
|
||||
# Ищем платеж по order_id (наш) или paypear_id
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
payment = None
|
||||
if order_id:
|
||||
payment = await paypear_crud.get_paypear_payment_by_order_id(db, order_id)
|
||||
if not payment and paypear_id:
|
||||
payment = await paypear_crud.get_paypear_payment_by_paypear_id(db, paypear_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'PayPear webhook: платеж не найден',
|
||||
order_id=order_id,
|
||||
paypear_id=paypear_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await paypear_crud.get_paypear_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('PayPear: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Проверка дублирования (re-check from locked row)
|
||||
if payment.is_paid:
|
||||
logger.info('PayPear webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = PAYPEAR_STATUS_MAP.get(paypear_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
# Если event = payment.confirmed, принудительно считаем оплаченным
|
||||
if is_confirmed:
|
||||
is_paid = True
|
||||
internal_status = 'success'
|
||||
|
||||
callback_payload = {
|
||||
'paypear_id': paypear_id,
|
||||
'order_id': order_id,
|
||||
'status': paypear_status,
|
||||
'event': event,
|
||||
'amount': obj.get('amount'),
|
||||
}
|
||||
|
||||
# Проверка суммы ДО обновления статуса
|
||||
if is_paid:
|
||||
amount_info = obj.get('amount', {})
|
||||
if isinstance(amount_info, dict):
|
||||
amount_value = amount_info.get('value')
|
||||
else:
|
||||
amount_value = amount_info
|
||||
|
||||
if amount_value is not None:
|
||||
received_kopeks = round(float(amount_value) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'PayPear amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await paypear_crud.update_paypear_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
paypear_id=paypear_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.paypear_id = paypear_id or payment.paypear_id
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_paypear_payment(db, payment, paypear_id=paypear_id, trigger='webhook')
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
payment = await paypear_crud.update_paypear_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
paypear_id=paypear_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('PayPear webhook: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_paypear_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
paypear_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')
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'PayPear платеж уже связан с транзакцией',
|
||||
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(paypear_id) if paypear_id else payment.order_id,
|
||||
provider_name='paypear',
|
||||
)
|
||||
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('Пользователь не найден для PayPear', 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(paypear_id) if paypear_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.PAYPEAR,
|
||||
)
|
||||
|
||||
display_name = settings.get_paypear_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.PAYPEAR,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await paypear_crud.link_paypear_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('PayPear платеж уже зачислил баланс ранее', 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.PAYPEAR,
|
||||
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('Ошибка обработки реферального пополнения PayPear', 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('Ошибка отправки админ уведомления PayPear', 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('Ошибка отправки уведомления пользователю PayPear', 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(
|
||||
'Обработан PayPear платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_paypear_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Проверяет статус платежа через API."""
|
||||
try:
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
payment = await paypear_crud.get_paypear_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
logger.warning('PayPear payment not found', order_id=order_id)
|
||||
return None
|
||||
|
||||
if payment.is_paid:
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
# Проверяем через API по paypear_id
|
||||
if payment.paypear_id:
|
||||
try:
|
||||
order_data = await paypear_service.get_payment(payment.paypear_id)
|
||||
paypear_status = order_data.get('status')
|
||||
|
||||
if paypear_status:
|
||||
status_info = PAYPEAR_STATUS_MAP.get(paypear_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
if is_paid:
|
||||
# Проверка суммы
|
||||
amount_info = order_data.get('amount', {})
|
||||
api_amount = amount_info.get('value') if isinstance(amount_info, dict) else amount_info
|
||||
if api_amount is not None:
|
||||
received_kopeks = round(float(api_amount) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'PayPear amount mismatch (API check)',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await paypear_crud.update_paypear_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
paypear_id=payment.paypear_id,
|
||||
callback_payload={
|
||||
'check_source': 'api',
|
||||
'paypear_order_data': order_data,
|
||||
},
|
||||
)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'amount_mismatch',
|
||||
'is_paid': False,
|
||||
}
|
||||
|
||||
# Acquire FOR UPDATE lock before finalization
|
||||
locked = await paypear_crud.get_paypear_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('PayPear: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return None
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('PayPear платеж уже обработан (api_check)', order_id=payment.order_id)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
logger.info('PayPear 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',
|
||||
'paypear_order_data': order_data,
|
||||
}
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await self._finalize_paypear_payment(
|
||||
db,
|
||||
payment,
|
||||
paypear_id=payment.paypear_id,
|
||||
trigger='api_check',
|
||||
)
|
||||
elif internal_status != payment.status:
|
||||
# Обновляем статус если изменился
|
||||
payment = await paypear_crud.update_paypear_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error checking PayPear 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('PayPear: ошибка проверки статуса', error=e)
|
||||
return None
|
||||
@@ -0,0 +1,629 @@
|
||||
"""Mixin для интеграции с RollyPay (rollypay.io)."""
|
||||
|
||||
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.rollypay_service import rollypay_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов RollyPay -> internal
|
||||
ROLLYPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'created': ('pending', False),
|
||||
'processing': ('processing', False),
|
||||
'paid': ('success', True),
|
||||
'expired': ('expired', False),
|
||||
'canceled': ('canceled', False),
|
||||
'chargeback': ('chargeback', False),
|
||||
}
|
||||
|
||||
|
||||
class RollyPayPaymentMixin:
|
||||
"""Mixin для работы с платежами RollyPay."""
|
||||
|
||||
async def create_rollypay_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:
|
||||
"""
|
||||
Создает платеж RollyPay.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_rollypay_enabled():
|
||||
logger.error('RollyPay не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.ROLLYPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'RollyPay: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
ROLLYPAY_MIN_AMOUNT_KOPEKS=settings.ROLLYPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.ROLLYPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'RollyPay: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
ROLLYPAY_MAX_AMOUNT_KOPEKS=settings.ROLLYPAY_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'rp{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
amount_value = f'{amount_rubles:.2f}'
|
||||
currency = settings.ROLLYPAY_CURRENCY
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
try:
|
||||
# Используем API для создания платежа
|
||||
result = await rollypay_service.create_payment(
|
||||
amount_value=amount_value,
|
||||
currency=currency,
|
||||
order_id=order_id,
|
||||
payment_method=payment_method_type,
|
||||
description=description,
|
||||
redirect_url=return_url or settings.ROLLYPAY_RETURN_URL,
|
||||
customer_id=str(tg_id),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
payment_url = result.get('pay_url')
|
||||
rollypay_payment_id = result.get('payment_id')
|
||||
|
||||
if not payment_url:
|
||||
logger.error('RollyPay API не вернул URL платежа', result=result)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'RollyPay API: создан платеж',
|
||||
order_id=order_id,
|
||||
rollypay_payment_id=rollypay_payment_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
# Срок действия из expires_at ответа или 30 минут по умолчанию
|
||||
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=30)
|
||||
else:
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=30)
|
||||
|
||||
# Сохраняем в БД
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
local_payment = await rollypay_crud.create_rollypay_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 or 'sbp',
|
||||
rollypay_payment_id=rollypay_payment_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'RollyPay: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'rollypay_payment_id': rollypay_payment_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('RollyPay: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
async def process_rollypay_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает webhook от RollyPay.
|
||||
|
||||
Подпись проверяется в webserver/payments.py до вызова этого метода.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело webhook (signature проверена в webserver)
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
event_type = payload.get('event_type')
|
||||
rollypay_payment_id = payload.get('payment_id')
|
||||
order_id = payload.get('order_id')
|
||||
rollypay_status = payload.get('status')
|
||||
|
||||
if not rollypay_payment_id or not rollypay_status:
|
||||
logger.warning('RollyPay webhook: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Определяем is_paid по event
|
||||
is_confirmed = event_type == 'payment.paid'
|
||||
|
||||
# Ищем платеж по order_id (наш) или rollypay_payment_id
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
payment = None
|
||||
if order_id:
|
||||
payment = await rollypay_crud.get_rollypay_payment_by_order_id(db, order_id)
|
||||
if not payment and rollypay_payment_id:
|
||||
payment = await rollypay_crud.get_rollypay_payment_by_rollypay_id(db, rollypay_payment_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'RollyPay webhook: платеж не найден',
|
||||
order_id=order_id,
|
||||
rollypay_payment_id=rollypay_payment_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await rollypay_crud.get_rollypay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('RollyPay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Проверка дублирования (re-check from locked row)
|
||||
if payment.is_paid:
|
||||
logger.info('RollyPay webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = ROLLYPAY_STATUS_MAP.get(rollypay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
# Если event = payment.paid, принудительно считаем оплаченным
|
||||
if is_confirmed:
|
||||
is_paid = True
|
||||
internal_status = 'success'
|
||||
|
||||
callback_payload = {
|
||||
'rollypay_payment_id': rollypay_payment_id,
|
||||
'order_id': order_id,
|
||||
'status': rollypay_status,
|
||||
'event_type': event_type,
|
||||
'amount': payload.get('amount'),
|
||||
'currency': payload.get('currency'),
|
||||
}
|
||||
|
||||
# Проверка суммы ДО обновления статуса
|
||||
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(
|
||||
'RollyPay amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await rollypay_crud.update_rollypay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
rollypay_payment_id=rollypay_payment_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.rollypay_payment_id = rollypay_payment_id or payment.rollypay_payment_id
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_rollypay_payment(
|
||||
db, payment, rollypay_payment_id=rollypay_payment_id, trigger='webhook'
|
||||
)
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
payment = await rollypay_crud.update_rollypay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
rollypay_payment_id=rollypay_payment_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('RollyPay webhook: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_rollypay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
rollypay_payment_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')
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'RollyPay платеж уже связан с транзакцией',
|
||||
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(rollypay_payment_id) if rollypay_payment_id else payment.order_id,
|
||||
provider_name='rollypay',
|
||||
)
|
||||
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('Пользователь не найден для RollyPay', 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(rollypay_payment_id) if rollypay_payment_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.ROLLYPAY,
|
||||
)
|
||||
|
||||
display_name = settings.get_rollypay_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.ROLLYPAY,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await rollypay_crud.link_rollypay_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('RollyPay платеж уже зачислил баланс ранее', 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.ROLLYPAY,
|
||||
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('Ошибка обработки реферального пополнения RollyPay', 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('Ошибка отправки админ уведомления RollyPay', 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('Ошибка отправки уведомления пользователю RollyPay', 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(
|
||||
'Обработан RollyPay платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_rollypay_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Проверяет статус платежа через API."""
|
||||
try:
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
payment = await rollypay_crud.get_rollypay_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
logger.warning('RollyPay payment not found', order_id=order_id)
|
||||
return None
|
||||
|
||||
if payment.is_paid:
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
# Проверяем через API по rollypay_payment_id
|
||||
if payment.rollypay_payment_id:
|
||||
try:
|
||||
order_data = await rollypay_service.get_payment(payment.rollypay_payment_id)
|
||||
rollypay_status = order_data.get('status')
|
||||
|
||||
if rollypay_status:
|
||||
status_info = ROLLYPAY_STATUS_MAP.get(rollypay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
if is_paid:
|
||||
# Проверка суммы
|
||||
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(
|
||||
'RollyPay amount mismatch (API check)',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await rollypay_crud.update_rollypay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
rollypay_payment_id=payment.rollypay_payment_id,
|
||||
callback_payload={
|
||||
'check_source': 'api',
|
||||
'rollypay_order_data': order_data,
|
||||
},
|
||||
)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'amount_mismatch',
|
||||
'is_paid': False,
|
||||
}
|
||||
|
||||
# Acquire FOR UPDATE lock before finalization
|
||||
locked = await rollypay_crud.get_rollypay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('RollyPay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return None
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('RollyPay платеж уже обработан (api_check)', order_id=payment.order_id)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
logger.info('RollyPay 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',
|
||||
'rollypay_order_data': order_data,
|
||||
}
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await self._finalize_rollypay_payment(
|
||||
db,
|
||||
payment,
|
||||
rollypay_payment_id=payment.rollypay_payment_id,
|
||||
trigger='api_check',
|
||||
)
|
||||
elif internal_status != payment.status:
|
||||
# Обновляем статус если изменился
|
||||
payment = await rollypay_crud.update_rollypay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error checking RollyPay 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('RollyPay: ошибка проверки статуса', error=e)
|
||||
return None
|
||||
@@ -146,6 +146,39 @@ def _get_method_defaults() -> dict:
|
||||
'default_max': settings.SEVERPAY_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': None,
|
||||
},
|
||||
'paypear': {
|
||||
'default_display_name': settings.get_paypear_display_name(),
|
||||
'is_configured': settings.is_paypear_enabled(),
|
||||
'default_min': settings.PAYPEAR_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.PAYPEAR_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'bank_card', 'name': 'Карта'},
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
{'id': 'sberpay', 'name': 'SberPay'},
|
||||
{'id': 'tpay', 'name': 'T-Pay'},
|
||||
],
|
||||
},
|
||||
'rollypay': {
|
||||
'default_display_name': settings.get_rollypay_display_name(),
|
||||
'is_configured': settings.is_rollypay_enabled(),
|
||||
'default_min': settings.ROLLYPAY_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.ROLLYPAY_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'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': 'СБП'},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -188,6 +221,9 @@ DEFAULT_METHOD_ORDER = [
|
||||
'kassa_ai',
|
||||
'riopay',
|
||||
'severpay',
|
||||
'paypear',
|
||||
'rollypay',
|
||||
'aurapay',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -30,10 +30,13 @@ 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
|
||||
from app.services.payment.paypear import PayPearPaymentMixin
|
||||
from app.services.payment.riopay import RioPayPaymentMixin
|
||||
from app.services.payment.rollypay import RollyPayPaymentMixin
|
||||
from app.services.payment.severpay import SeverPayPaymentMixin
|
||||
from app.services.platega_service import PlategaService
|
||||
from app.services.wata_service import WataService
|
||||
@@ -332,6 +335,114 @@ async def link_severpay_payment_to_transaction(*args, **kwargs):
|
||||
return await severpay_crud.link_severpay_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_paypear_payment(*args, **kwargs):
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
return await paypear_crud.create_paypear_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_paypear_payment_by_order_id(*args, **kwargs):
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
return await paypear_crud.get_paypear_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_paypear_payment_by_paypear_id(*args, **kwargs):
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
return await paypear_crud.get_paypear_payment_by_paypear_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_paypear_payment_by_id(*args, **kwargs):
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
return await paypear_crud.get_paypear_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_paypear_payment_by_id_for_update(*args, **kwargs):
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
return await paypear_crud.get_paypear_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_paypear_payment_status(*args, **kwargs):
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
return await paypear_crud.update_paypear_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_paypear_payment_to_transaction(*args, **kwargs):
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
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] = {
|
||||
@@ -355,6 +466,9 @@ class PaymentService(
|
||||
KassaAiPaymentMixin,
|
||||
RioPayPaymentMixin,
|
||||
SeverPayPaymentMixin,
|
||||
PayPearPaymentMixin,
|
||||
RollyPayPaymentMixin,
|
||||
AuraPayPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
|
||||
@@ -774,6 +888,72 @@ class PaymentService(
|
||||
}
|
||||
return None
|
||||
|
||||
# --- PayPear ----------------------------------------------------------
|
||||
if payment_method == 'paypear':
|
||||
if not settings.is_paypear_enabled():
|
||||
logger.warning('PayPear is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_paypear_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'], 'paypear')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('paypear_id') or result.get('order_id'),
|
||||
'provider': 'paypear',
|
||||
}
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Сервис для работы с API PayPear (paypear.ru)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
from base64 import b64encode
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
API_BASE_URL = 'https://api.paypear.ru/v1'
|
||||
|
||||
|
||||
class PayPearAPIError(Exception):
|
||||
"""Ошибка API PayPear."""
|
||||
|
||||
def __init__(self, status_code: int, message: str):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
super().__init__(f'PayPear API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class PayPearService:
|
||||
"""Сервис для работы с API PayPear."""
|
||||
|
||||
def __init__(self):
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def shop_id(self) -> str:
|
||||
return settings.PAYPEAR_SHOP_ID or ''
|
||||
|
||||
@property
|
||||
def secret_key(self) -> str:
|
||||
return settings.PAYPEAR_SECRET_KEY or ''
|
||||
|
||||
def _basic_auth_header(self) -> str:
|
||||
"""Генерирует HTTP Basic Auth заголовок."""
|
||||
credentials = f'{self.shop_id}:{self.secret_key}'
|
||||
encoded = b64encode(credentials.encode('utf-8')).decode('utf-8')
|
||||
return f'Basic {encoded}'
|
||||
|
||||
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
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
order_id: str,
|
||||
amount_rubles: float,
|
||||
currency: str = 'RUB',
|
||||
payment_method_type: str = 'sbp',
|
||||
description: str = '',
|
||||
return_url: str | None = None,
|
||||
webhook_url: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Создает платеж через API PayPear.
|
||||
POST /v1/payment/
|
||||
"""
|
||||
idempotence_key = str(uuid.uuid4())
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
'amount': {
|
||||
'value': f'{amount_rubles:.2f}',
|
||||
'currency': currency,
|
||||
},
|
||||
'order_id': order_id,
|
||||
'payment_method_data': {
|
||||
'type': payment_method_type,
|
||||
},
|
||||
}
|
||||
|
||||
if description:
|
||||
payload['description'] = description
|
||||
if return_url:
|
||||
payload['confirmation'] = {
|
||||
'type': 'redirect',
|
||||
'return_url': return_url,
|
||||
}
|
||||
if webhook_url:
|
||||
payload['webhook_url'] = webhook_url
|
||||
if metadata:
|
||||
payload['metadata'] = metadata
|
||||
|
||||
logger.info(
|
||||
'PayPear API create_payment',
|
||||
order_id=order_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
f'{API_BASE_URL}/payment/',
|
||||
json=payload,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': self._basic_auth_header(),
|
||||
'Idempotence-Key': idempotence_key,
|
||||
},
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status == 200 and data.get('success') is True:
|
||||
result = data.get('result', {})
|
||||
logger.info(
|
||||
'PayPear API payment created',
|
||||
order_id=order_id,
|
||||
paypear_id=result.get('id'),
|
||||
)
|
||||
return result
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'PayPear create_payment error',
|
||||
status_code=response.status,
|
||||
error_msg=error_msg,
|
||||
response_data=data,
|
||||
)
|
||||
raise PayPearAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('PayPear API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def get_payment(self, payment_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Получает информацию о платеже по ID.
|
||||
GET /v1/payment/{id}/
|
||||
"""
|
||||
logger.info('PayPear get_payment', payment_id=payment_id)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.get(
|
||||
f'{API_BASE_URL}/payment/{payment_id}/',
|
||||
headers={
|
||||
'Authorization': self._basic_auth_header(),
|
||||
},
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status == 200 and data.get('success') is True:
|
||||
return data.get('result', {})
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'PayPear get_payment error',
|
||||
status_code=response.status,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
raise PayPearAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('PayPear API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def get_payment_by_order_id(self, order_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Получает информацию о платеже по order_id.
|
||||
GET /v1/payment/order/{order_id}/
|
||||
"""
|
||||
logger.info('PayPear get_payment_by_order_id', order_id=order_id)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.get(
|
||||
f'{API_BASE_URL}/payment/order/{order_id}/',
|
||||
headers={
|
||||
'Authorization': self._basic_auth_header(),
|
||||
},
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status == 200 and data.get('success') is True:
|
||||
return data.get('result', {})
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'PayPear get_payment_by_order_id error',
|
||||
status_code=response.status,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
raise PayPearAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('PayPear API connection error', error=e)
|
||||
raise
|
||||
|
||||
def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool:
|
||||
"""Верификация подписи webhook PayPear через HMAC-SHA256.
|
||||
|
||||
PayPear sends signature in the webhook JSON field 'signature'.
|
||||
The signature is HMAC-SHA256(secret_key, raw_body).
|
||||
"""
|
||||
try:
|
||||
if not received_signature:
|
||||
logger.warning('PayPear webhook: отсутствует signature')
|
||||
return False
|
||||
|
||||
expected = hmac.new(
|
||||
self.secret_key.encode('utf-8'),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(expected, received_signature)
|
||||
except Exception as e:
|
||||
logger.error('PayPear webhook verify error', error=e)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
paypear_service = PayPearService()
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Сервис для работы с API RollyPay (rollypay.io)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
API_BASE_URL = 'https://rollypay.io/api/v1'
|
||||
|
||||
|
||||
class RollyPayAPIError(Exception):
|
||||
"""Ошибка API RollyPay."""
|
||||
|
||||
def __init__(self, status_code: int, message: str):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
super().__init__(f'RollyPay API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class RollyPayService:
|
||||
"""Сервис для работы с API RollyPay."""
|
||||
|
||||
def __init__(self):
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
return settings.ROLLYPAY_API_KEY or ''
|
||||
|
||||
@property
|
||||
def signing_secret(self) -> str:
|
||||
return settings.ROLLYPAY_SIGNING_SECRET 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-API-Key и X-Nonce."""
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': self.api_key,
|
||||
'X-Nonce': str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
amount_value: str,
|
||||
currency: str = 'RUB',
|
||||
order_id: str,
|
||||
payment_method: str | None = None,
|
||||
description: str = '',
|
||||
redirect_url: str | None = None,
|
||||
success_redirect_url: str | None = None,
|
||||
fail_redirect_url: str | None = None,
|
||||
customer_id: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Создает платеж через API RollyPay.
|
||||
POST /api/v1/payments
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'amount': amount_value,
|
||||
'payment_currency': currency,
|
||||
'order_id': order_id,
|
||||
}
|
||||
if payment_method:
|
||||
payload['payment_method'] = payment_method
|
||||
|
||||
if description:
|
||||
payload['description'] = description
|
||||
if redirect_url:
|
||||
payload['redirect_url'] = redirect_url
|
||||
if success_redirect_url:
|
||||
payload['success_redirect_url'] = success_redirect_url
|
||||
if fail_redirect_url:
|
||||
payload['fail_redirect_url'] = fail_redirect_url
|
||||
if customer_id:
|
||||
payload['customer_id'] = customer_id
|
||||
if metadata:
|
||||
payload['metadata'] = metadata
|
||||
|
||||
logger.info(
|
||||
'RollyPay API create_payment',
|
||||
order_id=order_id,
|
||||
amount=amount_value,
|
||||
currency=currency,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
f'{API_BASE_URL}/payments',
|
||||
json=payload,
|
||||
headers=self._build_headers(),
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status == 200:
|
||||
logger.info(
|
||||
'RollyPay API payment created',
|
||||
order_id=order_id,
|
||||
payment_id=data.get('payment_id'),
|
||||
pay_url=data.get('pay_url'),
|
||||
)
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'RollyPay create_payment error',
|
||||
status_code=response.status,
|
||||
error_msg=error_msg,
|
||||
response_data=data,
|
||||
)
|
||||
raise RollyPayAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('RollyPay API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def get_payment(self, payment_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Получает информацию о платеже по ID.
|
||||
GET /api/v1/payments/{paymentID}
|
||||
"""
|
||||
logger.info('RollyPay get_payment', payment_id=payment_id)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.get(
|
||||
f'{API_BASE_URL}/payments/{payment_id}',
|
||||
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(
|
||||
'RollyPay get_payment error',
|
||||
status_code=response.status,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
raise RollyPayAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('RollyPay API connection error', error=e)
|
||||
raise
|
||||
|
||||
def verify_webhook_signature(self, raw_body: bytes, received_signature: str, timestamp: str) -> bool:
|
||||
"""Верификация подписи webhook RollyPay через HMAC-SHA256.
|
||||
|
||||
Signature = HMAC-SHA256(signing_secret, f"{timestamp}.{raw_body_str}")
|
||||
Headers: X-Signature, X-Timestamp
|
||||
"""
|
||||
try:
|
||||
if not received_signature or not timestamp:
|
||||
logger.warning('RollyPay webhook: отсутствует signature или timestamp')
|
||||
return False
|
||||
|
||||
raw_body_str = raw_body.decode('utf-8')
|
||||
message = f'{timestamp}.{raw_body_str}'
|
||||
|
||||
expected = hmac.new(
|
||||
self.signing_secret.encode('utf-8'),
|
||||
message.encode('utf-8'),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(expected, received_signature)
|
||||
except Exception as e:
|
||||
logger.error('RollyPay webhook verify error', error=e)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
rollypay_service = RollyPayService()
|
||||
@@ -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',
|
||||
|
||||
@@ -17,7 +17,7 @@ _DEFAULTS: dict[str, Any] = {
|
||||
'subscription_expiry_days': 3,
|
||||
'traffic_warning_enabled': True,
|
||||
'traffic_warning_percent': 80,
|
||||
'balance_low_enabled': True,
|
||||
'balance_low_enabled': False,
|
||||
'balance_low_threshold': 100, # kopeks
|
||||
'news_enabled': True,
|
||||
'promo_offers_enabled': True,
|
||||
|
||||
@@ -1241,6 +1241,154 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# PayPear webhook
|
||||
if settings.is_paypear_enabled():
|
||||
|
||||
@router.get(settings.PAYPEAR_WEBHOOK_PATH)
|
||||
async def paypear_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'paypear_webhook',
|
||||
'enabled': settings.is_paypear_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.PAYPEAR_WEBHOOK_PATH)
|
||||
async def paypear_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('PayPear webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Извлекаем подпись из тела webhook
|
||||
received_signature = payload.get('signature', '')
|
||||
|
||||
from app.services.paypear_service import paypear_service
|
||||
|
||||
if not paypear_service.verify_webhook_signature(raw_body, received_signature):
|
||||
logger.warning('PayPear webhook: invalid signature')
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_paypear_webhook',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'PayPear webhook processing failed',
|
||||
data=payload.get('object'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('PayPear webhook processing error', error=e)
|
||||
# Always return 200 — PayPear may retry on non-200
|
||||
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# RollyPay webhook
|
||||
if settings.is_rollypay_enabled():
|
||||
|
||||
@router.get(settings.ROLLYPAY_WEBHOOK_PATH)
|
||||
async def rollypay_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'rollypay_webhook',
|
||||
'enabled': settings.is_rollypay_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.ROLLYPAY_WEBHOOK_PATH)
|
||||
async def rollypay_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('RollyPay webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Подпись через заголовки X-Signature и X-Timestamp
|
||||
received_signature = request.headers.get('X-Signature', '')
|
||||
timestamp = request.headers.get('X-Timestamp', '')
|
||||
|
||||
from app.services.rollypay_service import rollypay_service
|
||||
|
||||
if not rollypay_service.verify_webhook_signature(raw_body, received_signature, timestamp):
|
||||
logger.warning('RollyPay webhook: invalid signature')
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_rollypay_webhook',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'RollyPay webhook processing failed',
|
||||
data=payload.get('payment_id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('RollyPay webhook processing error', error=e)
|
||||
# Always return 200 — RollyPay retries on non-200 with exponential backoff
|
||||
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
|
||||
|
||||
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')
|
||||
@@ -1261,6 +1409,9 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
'kassa_ai_enabled': settings.is_kassa_ai_enabled(),
|
||||
'riopay_enabled': settings.is_riopay_enabled(),
|
||||
'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 paypear_payments table
|
||||
|
||||
Revision ID: 0058
|
||||
Revises: 0057
|
||||
Create Date: 2026-04-15
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0058'
|
||||
down_revision: Union[str, None] = '0057'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'paypear_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('paypear_id', sa.String(64), 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('paypear_payments')
|
||||
@@ -0,0 +1,47 @@
|
||||
"""create rollypay_payments table
|
||||
|
||||
Revision ID: 0059
|
||||
Revises: 0058
|
||||
Create Date: 2026-04-15
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0059'
|
||||
down_revision: Union[str, None] = '0058'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'rollypay_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('rollypay_payment_id', sa.String(128), unique=True, nullable=True, index=True),
|
||||
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
|
||||
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
|
||||
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||
sa.Column('payment_url', sa.Text(), nullable=True),
|
||||
sa.Column('payment_method', sa.String(32), nullable=True),
|
||||
sa.Column('metadata_json', sa.JSON(), nullable=True),
|
||||
sa.Column('callback_payload', sa.JSON(), nullable=True),
|
||||
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column(
|
||||
'transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('rollypay_payments')
|
||||
@@ -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.47.0"
|
||||
version = "3.49.0"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
|
||||
@@ -564,7 +564,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
|
||||
@@ -1143,7 +1142,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remnawave-bedolaga-telegram-bot"
|
||||
version = "3.45.1"
|
||||
version = "3.46.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiogram" },
|
||||
|
||||
Reference in New Issue
Block a user