Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29d3e8984b | |||
| b5a066628f | |||
| d6442b87df | |||
| 31e3ccd24c | |||
| 3d4b7b4582 | |||
| cedf4922fd | |||
| 491f09d547 | |||
| 0d0646770d | |||
| 17732a0370 | |||
| cd8be32671 | |||
| afea054c8f | |||
| f321ded9c0 | |||
| 3fce64858c | |||
| 1ab1ff90bf | |||
| 719664208e | |||
| 6524f66da2 | |||
| 17ac3da3c4 | |||
| e85c40f8cd | |||
| ecde2fb8f0 | |||
| 99648a956e | |||
| 2478ff7c3d | |||
| 2385814d77 | |||
| df7e397745 | |||
| 52868eac5b | |||
| 4c600b8557 | |||
| 51dfc3a1a2 | |||
| 443a826402 | |||
| 06db393488 | |||
| 0bcb804118 | |||
| 735e16afeb | |||
| a88e3c80ad | |||
| 1110d0c781 | |||
| 62e7ecba01 | |||
| c905fa6000 | |||
| 768e0b6a73 | |||
| 83efc214fe | |||
| 29e177d396 | |||
| 2fbdbf5ab0 | |||
| 422844d78d | |||
| f37eb9a1bd | |||
| 1c38b31e60 | |||
| 43dd0fd92c | |||
| a506c6be00 | |||
| ff7b190527 | |||
| 527c5b4498 | |||
| bada41ecd6 | |||
| cf60ae2967 | |||
| 47c7d45793 | |||
| 4ab5928b61 | |||
| fb857d792b | |||
| 59080f7392 | |||
| c619dbcae2 | |||
| 91de6d03fc | |||
| 1fc04d842f | |||
| e22beb7229 | |||
| 74999fe99d | |||
| 134e7fb0e1 | |||
| c743fc81a5 | |||
| 579e4f2a69 | |||
| b9b695799c | |||
| 5cf19c76e6 | |||
| eafb243882 |
+4
-7
@@ -72,8 +72,11 @@ SMTP_PASSWORD=
|
||||
# Email отправителя (если не указан, используется SMTP_USER)
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_FROM_NAME=VPN Service
|
||||
# Использовать TLS шифрование
|
||||
# Использовать STARTTLS (порт 587 / 25). Не путать с SMTP_USE_SSL.
|
||||
SMTP_USE_TLS=true
|
||||
# Использовать implicit TLS (SMTPS). Автоматически включается при SMTP_PORT=465.
|
||||
# Для портов 25/587 оставить false.
|
||||
SMTP_USE_SSL=false
|
||||
|
||||
# Уведомления администраторов
|
||||
ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
@@ -1029,10 +1032,4 @@ WEB_API_TOKEN_HASH_ALGORITHM=sha256
|
||||
# Логирование запросов
|
||||
WEB_API_REQUEST_LOGGING=true
|
||||
|
||||
# Внешний админ-токен (для интеграции с другими ботами/системами)
|
||||
# Токен для доступа через API другого бота
|
||||
# EXTERNAL_ADMIN_TOKEN=
|
||||
# ID бота, от которого принимается токен
|
||||
# EXTERNAL_ADMIN_TOKEN_BOT_ID=
|
||||
|
||||
MINIAPP_STATIC_PATH=miniapp
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.52.1"
|
||||
".": "3.54.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,80 @@
|
||||
# Changelog
|
||||
|
||||
## [3.54.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.53.0...v3.54.0) (2026-05-04)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add subscription reissue with 15-min cooldown ([1ab1ff9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ab1ff90bf6fc243da45a53b2d549464e47c2f31))
|
||||
* Apple IAP integration with security hardening ([ecde2fb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ecde2fb8f05620889f45fea4410e9069b99340a5))
|
||||
* integrate Antilopay payment provider (API v2) ([7196642](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/719664208e94fe1e528ff0f85f500e5b96555dc9))
|
||||
* integrate Etoplatezhi payment provider ([6524f66](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6524f66da2717001aba99831f8dec93ad8f6e6f8))
|
||||
* integrate Jupiter (FPGate P2P) and Donut payment providers ([f321ded](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f321ded9c0033ff3848a92aef44cbfef7f8d3883))
|
||||
* integrate Lava Business payment provider ([afea054](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/afea054c8f8898d076d667c2d17248052578cbce))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add pycryptodome dependency for Antilopay RSA signing ([3fce648](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3fce64858cc4e536ab32acc4c68213c208b6081c))
|
||||
* apple refund handler — lock apple_transactions row to prevent double deduction ([e85c40f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e85c40f8cdca1e3392da06f6539f0043f923b731))
|
||||
* AuraPay webhook signature + add SBP/Card payment method selection ([17ac3da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17ac3da3c4da0f36015e338451a22a1a7a433524))
|
||||
* expired_1d notification — use PricingEngine instead of hardcoded PRICE_30_DAYS ([2478ff7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2478ff7c3d0c426c5f64dec71d17df13de60702a))
|
||||
* guide mode buttons — support external type alias, extract urlScheme from blocks ([2385814](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2385814d77c6b847fb0df248b5aaaa2ff27fd20e))
|
||||
* persist campaign across bot→webapp registration handoff via Redis ([99648a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/99648a956e20cb99d406455db2f26db469ba7235))
|
||||
* register all providers in payment search and verification services ([cd8be32](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cd8be326716e2c3253d9b2935da1a7927725c9af))
|
||||
* **tests:** clean up pre-existing ruff lint warnings in apple_iap tests ([0d06467](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0d0646770d2a1cdccec14ea3b78dc12412f95d0a))
|
||||
|
||||
## [3.53.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.52.1...v3.53.0) (2026-04-29)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add bulk_actions, info_pages, news to PERMISSION_REGISTRY ([06db393](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/06db3934881bc55851e1ff171fca89abc7deebe5))
|
||||
* per-category enable/disable for admin notifications ([fb857d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fb857d792b7dd6658df7c081ef46b4cc729cd2fa))
|
||||
* protect active paid subscriptions from bulk delete ([51dfc3a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/51dfc3a1a2a31706b5c307f6394f2ef9f578cc51))
|
||||
* subscription request history API + RemnaWave panel method ([e22beb7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e22beb722934779a6d10cb9a7c9a1853f68f7787))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add 5 missing payment providers to pending-payments model_map ([a506c6b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a506c6be004054998354286fc9dc3990aa867ccc))
|
||||
* add 6 missing payment providers to payment_utils availability checks ([83efc21](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/83efc214fef7cdec438cf39e4301e6d22bceec7e))
|
||||
* add RollyPay, PayPear, Overpay, AuraPay to REAL_PAYMENT_METHODS ([ff7b190](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff7b1905271e92625c10fa22d809f27c22a496b6))
|
||||
* autopay renewing trial subscriptions at classic-mode pricing ([2fbdbf5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2fbdbf5ab0ec0c13100e330f60a2249ed866c12e))
|
||||
* backup completeness — add 15 missing tables, accept .tar.gz uploads ([eafb243](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eafb243882a2f325771e397cdcc3b258f8ec8f7a))
|
||||
* backup import crash + upload handler hardening ([5cf19c7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5cf19c76e6fa9456cee0157ac4a4c0742d0f7718))
|
||||
* block/unblock endpoints — correct args, response schema, panel sync ([0bcb804](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0bcb804118fa9cb02c580eea849b6cd218d931f8))
|
||||
* cabinet /block endpoint now disables panel user in RemnaWave ([735e16a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/735e16afeba70ef22e690d668e6693cdd2bac140))
|
||||
* cabinet autopay endpoint — same NULL-safe is_trial guard ([29e177d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29e177d396796827e3970015afb5a84512612035))
|
||||
* cabinet purchase fails after panel user deletion — stale UUID ([f37eb9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f37eb9a1bd6a149f38458b1a4a1071efd8c03660))
|
||||
* callback.answer() before heavy operations to prevent query timeout ([579e4f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/579e4f2a695315091db7f014d6a2241852b078ae))
|
||||
* create locales directory with correct permissions in Dockerfile ([74999fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/74999fe99dd5ef7b10814cb24ff3022aca1d1c0d))
|
||||
* deadlock on user deletion — webhook handler never checked intentional mark ([62e7ecb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/62e7ecba01601ab3d5aa6c913db064d5fa768d9a))
|
||||
* device/traffic addon pricing — use ceil instead of floor for days_left ([cf60ae2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf60ae2967b8a5b0c42c51354e3a2513c38e7120))
|
||||
* downgrade Pal24 API validation errors from error to debug ([c905fa6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c905fa60000c238475cdc3b253ae6e67aa670ff4))
|
||||
* false subscription expiry notifications — 4 bugs fixed ([134e7fb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/134e7fb0e1999f6404e2d06f38aebdb6af550ec1))
|
||||
* handle A018 error code as user-not-found fallback to create_user ([c619dbc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c619dbcae2a1470d132cdd35cb9824ac801f117c))
|
||||
* handle A018 error code in admin_users sync endpoints (2 more locations) ([59080f7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59080f7392412cae848fdd3887cb25304a3a33f9))
|
||||
* media upload leaks staging photo to admin chat ([1110d0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1110d0c7810d52d4f8f789dc5383cf4a0f5fce96))
|
||||
* panel sync subscription duration — ceil for days_remaining ([527c5b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/527c5b4498972e2806480af1d0625939eae50bee))
|
||||
* PayPear webhook signature — strip signature field before hashing + IP fallback ([443a826](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/443a826402e63b021f1efc856257bbf54c79fdb4))
|
||||
* PollResponse has no created_at — use sent_at for ordering ([768e0b6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/768e0b6a7363e8b496f1e1b83fe567993dc664da))
|
||||
* promo group discount applied to restricted tariffs in autopay ([4ab5928](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4ab5928b61c35b4af115ced949e373c3373c7145))
|
||||
* referral links now clickable — remove <code> wrapping ([43dd0fd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43dd0fd92c433498d780ca4e8798d606f5f70dbf))
|
||||
* remaining pricing-critical .days floor calculations → math.ceil ([bada41e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bada41ecd67e81020627dc50e90d357c088471ab))
|
||||
* replace all late callback.answer() with edit_text for error feedback ([c743fc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c743fc81a5cb56153dbfe49e4f293277d773e948))
|
||||
* retry queue action uses _should_create instead of stale subscription UUID ([422844d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/422844d78df2865926c18d888edfb95e72c077c7))
|
||||
* send admin notification on promo code activation from cabinet ([1c38b31](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1c38b31e60f2491c2366f43b792400af71cba70d))
|
||||
* subscription-request-history — correct API client usage, add ownership check ([1fc04d8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1fc04d842fe589df1ec6208d6709b5bd55c85da7))
|
||||
* traffic addon discount also bypassed tariff-promo-group check ([47c7d45](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/47c7d4579336833213203f3e127d4155ed25b555))
|
||||
* traffic addon price mismatch — keyboard showed prorated, handler charged full month ([a88e3c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a88e3c80ade1a1178270efe5e4100810da6f79b5))
|
||||
* update cabinet_last_login on every request (throttled, 5 min) ([91de6d0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91de6d03fce4d6082359e7a1ba7e7f5ec02756b6))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove unused EXTERNAL_ADMIN_TOKEN functionality ([b9b6957](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b9b695799cb15f0f3be0116063cbf72482096b5d))
|
||||
|
||||
## [3.52.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.52.0...v3.52.1) (2026-04-24)
|
||||
|
||||
|
||||
|
||||
+3
-3
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.52.1" # x-release-please-version
|
||||
ARG VERSION="v3.54.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
@@ -33,8 +33,8 @@ WORKDIR /app
|
||||
|
||||
COPY --chown=app:app . .
|
||||
|
||||
RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails && \
|
||||
chown -R app:app logs data uploads
|
||||
RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails locales && \
|
||||
chown -R app:app logs data uploads locales
|
||||
|
||||
USER app
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ Bedolaga — полнофункциональная платформа для п
|
||||
|
||||
### 💳 Платежи
|
||||
|
||||
- 🏦 **18 платёжных провайдеров** одновременно
|
||||
- 🏦 **24+ платёжных провайдера** одновременно
|
||||
- 💰 Единый баланс: пополнение любым способом → покупка с баланса
|
||||
- ⚡ Автопокупка подписки после пополнения
|
||||
- 💾 Рекуррентные платежи (сохранённые карты)
|
||||
@@ -127,6 +127,12 @@ Bedolaga — полнофункциональная платформа для п
|
||||
| 🤝 | **[RollyPay](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)** 🔸 | СБП, карты, крипто | RUB → USDT |
|
||||
| 🤝 | **[AuraPay](https://aurapay.tech/)** 🔸 | Карты, СБП | RUB |
|
||||
| 🤝 | **[Overpay](https://overpay.pro/)** 🔸 | Карты, СБП | RUB |
|
||||
| 🦌 | **Antilopay** | Карты, СБП, SberPay (RSA подпись) | RUB |
|
||||
| 💳 | **Etoplatezhi** | Карты, СБП | RUB |
|
||||
| 🪐 | **[Jupiter](https://t.me/k_juppiter)** 🔸 | СБП через QR (FPGate P2P v2.1) | RUB |
|
||||
| 🍩 | **[Donut](https://t.me/donut_payment)** 🔸 | Карты, СБП по телефону, СБП QR (P2P) | RUB |
|
||||
| 🌋 | **Lava Business** | Карты, СБП (gate.lava.ru) | RUB |
|
||||
| 🍎 | **Apple In-App Purchase** | Покупки через iOS App Store | USD |
|
||||
| 📲 | **Tribute** | Telegram-платежи | RUB |
|
||||
|
||||
</div>
|
||||
@@ -210,6 +216,32 @@ Bedolaga — официальный партнёр платёжного шлюз
|
||||
|
||||
📩 Менеджер: [@A_OverPay](https://t.me/A_OverPay) | 🌐 [overpay.pro](https://overpay.pro/)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр Jupiter (FPGate P2P)**
|
||||
|
||||
Bedolaga — официальный партнёр платёжного шлюза **Jupiter** (FPGate P2P v2.1).<br>
|
||||
Эквайринг СБП через QR-код банковского приложения, HMAC-SHA256 подпись.<br>
|
||||
Высокая проходимость, callback-driven архитектура, защита от replay-атак.<br>
|
||||
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
|
||||
|
||||
📩 Менеджер: [@k_juppiter](https://t.me/k_juppiter)
|
||||
|
||||
</td>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр Donut**
|
||||
|
||||
Bedolaga — официальный партнёр платёжной системы **Donut** (Donut P2P).<br>
|
||||
P2P-оплата картой, СБП по номеру телефона и СБП QR — три метода через единый API.<br>
|
||||
HMAC-SHA256 подпись, sticky terminal-status guard, защита от amount tampering.<br>
|
||||
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
|
||||
|
||||
📩 Менеджер: [@donut_payment](https://t.me/donut_payment)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -275,7 +307,7 @@ docker compose up -d
|
||||
| | Раздел | Описание |
|
||||
|:---:|:---|:---|
|
||||
| 🚀 | [Быстрый старт](https://docs.bedolagam.ru/getting-started/quickstart) | Развёртывание за 5 минут |
|
||||
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 18 провайдеров, webhook, фискализация |
|
||||
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 24+ провайдера, webhook, фискализация, Apple IAP |
|
||||
| 📦 | [Подписки и тарифы](https://docs.bedolagam.ru/bot/subscriptions) | Конфигурация планов и трафика |
|
||||
| 👥 | [Реферальная программа](https://docs.bedolagam.ru/bot/referral-program) | Партнёрка и вывод средств |
|
||||
| 🖥 | [Cabinet](https://docs.bedolagam.ru/cabinet/overview) | Настройка веб-кабинета |
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""FastAPI dependencies for cabinet module."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
@@ -176,6 +178,15 @@ async def get_current_cabinet_user(
|
||||
},
|
||||
)
|
||||
|
||||
# Throttled update of cabinet_last_login (at most every 5 minutes)
|
||||
now = datetime.now(UTC)
|
||||
if not user.cabinet_last_login or (now - user.cabinet_last_login).total_seconds() > 300:
|
||||
try:
|
||||
user.cabinet_last_login = now
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -69,6 +69,13 @@ from .wheel import router as wheel_router
|
||||
from .withdrawal import router as withdrawal_router
|
||||
|
||||
|
||||
# Conditional imports
|
||||
try:
|
||||
from .apple_iap import router as apple_iap_router
|
||||
except ImportError:
|
||||
apple_iap_router = None
|
||||
|
||||
|
||||
# Main cabinet router
|
||||
router = APIRouter(prefix='/cabinet', tags=['Cabinet'], redirect_slashes=False)
|
||||
|
||||
@@ -81,6 +88,11 @@ router.include_router(subscription_router)
|
||||
router.include_router(multi_tariff_subscription_router)
|
||||
router.include_router(balance_router)
|
||||
router.include_router(referral_router)
|
||||
|
||||
# Apple IAP routes
|
||||
if apple_iap_router is not None:
|
||||
router.include_router(apple_iap_router)
|
||||
|
||||
router.include_router(partner_application_router)
|
||||
router.include_router(withdrawal_router)
|
||||
# Notifications router MUST be before tickets router to avoid route conflict
|
||||
|
||||
@@ -502,6 +502,16 @@ async def _do_delete_subscription(
|
||||
|
||||
tariff_name = sub.tariff.name if sub.tariff else f'#{sub.id}'
|
||||
|
||||
# Protect active paid subscriptions from accidental deletion
|
||||
if sub.is_active and not sub.is_trial and not params.force_delete_active_paid:
|
||||
return BulkUserResult(
|
||||
user_id=user.id,
|
||||
success=False,
|
||||
message=f'Skipped: {tariff_name} is active and paid (enable force_delete_active_paid to override)',
|
||||
username=user.username,
|
||||
subscriptions=_build_subscription_info(getattr(user, 'subscriptions', None) or []),
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return BulkUserResult(
|
||||
user_id=user.id,
|
||||
@@ -890,7 +900,7 @@ async def _execute_for_subscription(
|
||||
async def bulk_execute(
|
||||
request: BulkExecuteRequest,
|
||||
stream: bool = Query(default=False, description='Stream progress via SSE'),
|
||||
admin: User = Depends(require_permission('users:edit')),
|
||||
admin: User = Depends(require_permission('bulk_actions:execute')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Execute a bulk action on multiple users or subscriptions.
|
||||
|
||||
@@ -34,7 +34,7 @@ router = APIRouter(prefix='/admin/info-pages', tags=['Cabinet Admin Info Pages']
|
||||
@router.get('', response_model=list[InfoPageListItem])
|
||||
async def list_all_info_pages(
|
||||
page_type: str | None = Query(None, pattern=r'^(page|faq)$'),
|
||||
admin: User = Depends(require_permission('settings:read')),
|
||||
admin: User = Depends(require_permission('info_pages:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[InfoPageListItem]:
|
||||
"""Get all info pages (admin view, includes inactive)."""
|
||||
@@ -54,7 +54,7 @@ async def list_all_info_pages(
|
||||
@router.get('/{page_id}', response_model=InfoPageResponse)
|
||||
async def get_info_page_detail(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('settings:read')),
|
||||
admin: User = Depends(require_permission('info_pages:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Get a single info page by ID (admin view)."""
|
||||
@@ -70,7 +70,7 @@ async def get_info_page_detail(
|
||||
@router.post('', response_model=InfoPageResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_page(
|
||||
request: InfoPageCreateRequest,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Create a new info page."""
|
||||
@@ -108,7 +108,7 @@ async def create_page(
|
||||
async def update_page(
|
||||
page_id: int,
|
||||
request: InfoPageUpdateRequest,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Update an existing info page."""
|
||||
@@ -150,7 +150,7 @@ async def update_page(
|
||||
@router.delete('/{page_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_page(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete an info page."""
|
||||
@@ -174,7 +174,7 @@ async def remove_page(
|
||||
@router.post('/reorder', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def reorder_pages(
|
||||
request: ReorderRequest,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Bulk update sort_order for info pages."""
|
||||
@@ -191,7 +191,7 @@ async def reorder_pages(
|
||||
@router.post('/{page_id}/toggle-active', response_model=InfoPageResponse)
|
||||
async def toggle_active(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Toggle the active status of an info page."""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Admin routes for managing users in cabinet."""
|
||||
|
||||
import math
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
@@ -157,6 +158,7 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt
|
||||
tariff_id=s.tariff_id,
|
||||
tariff_name=s.tariff.name if s.tariff else None,
|
||||
status=s.status,
|
||||
is_trial=bool(s.is_trial),
|
||||
end_date=s.end_date,
|
||||
days_remaining=s_days,
|
||||
traffic_used_gb=s.traffic_used_gb or 0.0,
|
||||
@@ -391,7 +393,10 @@ async def _sync_subscription_to_panel(
|
||||
changes['action'] = 'updated'
|
||||
logger.info('Updated user in Remnawave panel', user_id=user.id)
|
||||
except Exception as update_error:
|
||||
if hasattr(update_error, 'status_code') and update_error.status_code == 404:
|
||||
error_code = (getattr(update_error, 'response_data', None) or {}).get('errorCode', '')
|
||||
if (
|
||||
hasattr(update_error, 'status_code') and update_error.status_code == 404
|
||||
) or error_code == 'A018':
|
||||
panel_uuid = None # Will create new
|
||||
else:
|
||||
raise
|
||||
@@ -895,6 +900,50 @@ async def get_user_panel_info(
|
||||
return UserPanelInfoResponse(found=False)
|
||||
|
||||
|
||||
@router.get('/{user_id}/subscription-request-history')
|
||||
async def get_subscription_request_history(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
"""Get subscription request history from RemnaWave panel."""
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
|
||||
|
||||
panel_uuid = None
|
||||
if settings.is_multi_tariff_enabled() and subscription_id:
|
||||
from app.database.crud.subscription import get_subscription_by_id_for_user
|
||||
|
||||
sub = await get_subscription_by_id_for_user(db, subscription_id, user_id)
|
||||
if sub:
|
||||
panel_uuid = sub.remnawave_uuid
|
||||
else:
|
||||
panel_uuid = getattr(user, 'remnawave_uuid', None)
|
||||
|
||||
if not panel_uuid:
|
||||
return {'total': 0, 'records': []}
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured:
|
||||
return {'total': 0, 'records': []}
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
result = await api.get_subscription_request_history(panel_uuid, offset=offset, limit=limit)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error('Error getting subscription request history', user_id=user_id, error=e)
|
||||
return {'total': 0, 'records': []}
|
||||
|
||||
|
||||
@router.get('/{user_id}/node-usage', response_model=UserNodeUsageResponse)
|
||||
async def get_user_node_usage(
|
||||
user_id: int,
|
||||
@@ -1743,9 +1792,25 @@ async def block_user(
|
||||
admin: User = Depends(require_permission('users:block')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Block a user (shortcut for status update)."""
|
||||
request = UpdateUserStatusRequest(status=UserStatusEnum.BLOCKED, reason=reason)
|
||||
return await update_user_status(user_id, request, admin, db)
|
||||
"""Block a user — sets DB status AND disables panel user in RemnaWave."""
|
||||
from app.services.user_service import UserService
|
||||
|
||||
user_service = UserService()
|
||||
success = await user_service.block_user(
|
||||
db,
|
||||
user_id,
|
||||
admin.id,
|
||||
reason=reason or 'Заблокирован администратором',
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found or block failed')
|
||||
|
||||
return UpdateUserStatusResponse(
|
||||
success=True,
|
||||
old_status='active',
|
||||
new_status='blocked',
|
||||
message='User blocked',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{user_id}/unblock', response_model=UpdateUserStatusResponse)
|
||||
@@ -1754,9 +1819,20 @@ async def unblock_user(
|
||||
admin: User = Depends(require_permission('users:block')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Unblock a user (shortcut for status update)."""
|
||||
request = UpdateUserStatusRequest(status=UserStatusEnum.ACTIVE)
|
||||
return await update_user_status(user_id, request, admin, db)
|
||||
"""Unblock a user — sets DB status AND re-enables panel user in RemnaWave."""
|
||||
from app.services.user_service import UserService
|
||||
|
||||
user_service = UserService()
|
||||
success = await user_service.unblock_user(db, user_id, admin.id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found or unblock failed')
|
||||
|
||||
return UpdateUserStatusResponse(
|
||||
success=True,
|
||||
old_status='blocked',
|
||||
new_status='active',
|
||||
message='User unblocked',
|
||||
)
|
||||
|
||||
|
||||
# === Restrictions Management ===
|
||||
@@ -3159,7 +3235,7 @@ async def sync_user_from_panel(
|
||||
int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 100
|
||||
)
|
||||
panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at)
|
||||
days_remaining = max(1, (panel_expire_utc - datetime.now(UTC)).days)
|
||||
days_remaining = max(1, math.ceil((panel_expire_utc - datetime.now(UTC)).total_seconds() / 86400))
|
||||
|
||||
new_sub = await create_paid_subscription(
|
||||
db=db,
|
||||
@@ -3362,7 +3438,10 @@ async def sync_user_to_panel(
|
||||
await api.update_user(**update_kwargs)
|
||||
action = 'updated'
|
||||
except Exception as update_error:
|
||||
if hasattr(update_error, 'status_code') and update_error.status_code == 404:
|
||||
error_code = (getattr(update_error, 'response_data', None) or {}).get('errorCode', '')
|
||||
if (
|
||||
hasattr(update_error, 'status_code') and update_error.status_code == 404
|
||||
) or error_code == 'A018':
|
||||
# User not found in panel, create new
|
||||
panel_uuid = None
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Apple In-App Purchase cabinet route."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.apple_iap import (
|
||||
create_apple_transaction,
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction as create_trans
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
from app.database.models import PaymentMethod, TransactionType, User
|
||||
from app.external.apple_iap import AppleIAPService
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.apple_iap import ApplePurchaseRequest, ApplePurchaseResponse
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(tags=['Cabinet Apple IAP'])
|
||||
|
||||
|
||||
def get_apple_iap_service() -> AppleIAPService:
|
||||
return AppleIAPService()
|
||||
|
||||
|
||||
@router.post('/apple-purchase', response_model=ApplePurchaseResponse)
|
||||
async def apple_purchase(
|
||||
request: ApplePurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
apple_iap_service: AppleIAPService = Depends(get_apple_iap_service),
|
||||
):
|
||||
"""Verify an Apple In-App Purchase and credit the user's balance.
|
||||
|
||||
The iOS app calls this endpoint after a successful StoreKit transaction.
|
||||
If the backend returns success=false, the iOS app will NOT finish the
|
||||
transaction and will retry on next launch.
|
||||
"""
|
||||
if not settings.is_apple_iap_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Apple In-App Purchase is not enabled',
|
||||
)
|
||||
|
||||
# Validate product ID
|
||||
products = settings.get_apple_iap_products()
|
||||
if request.product_id not in products:
|
||||
logger.warning(
|
||||
'Unknown Apple product ID',
|
||||
product_id=request.product_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Unknown product ID',
|
||||
)
|
||||
|
||||
amount_kopeks = products[request.product_id]
|
||||
|
||||
# Verify transaction with Apple Server API (no DB lock needed).
|
||||
# verify_transaction automatically falls back Sandbox<->Production.
|
||||
txn_info = await apple_iap_service.verify_transaction(request.transaction_id, settings.APPLE_IAP_ENVIRONMENT)
|
||||
if not txn_info:
|
||||
logger.warning(
|
||||
'Apple transaction verification failed',
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=False)
|
||||
|
||||
# Validate transaction fields
|
||||
validation_error = apple_iap_service.validate_transaction_info(txn_info, request.product_id)
|
||||
if validation_error:
|
||||
logger.warning(
|
||||
'Apple transaction validation failed',
|
||||
error=validation_error,
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=False)
|
||||
|
||||
# FIX 4: appAccountToken is mandatory -- reject if missing
|
||||
app_account_token = txn_info.get('appAccountToken')
|
||||
if not app_account_token:
|
||||
logger.warning(
|
||||
'Apple appAccountToken missing -- rejecting transaction',
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=False)
|
||||
|
||||
if app_account_token != str(user.id):
|
||||
logger.warning(
|
||||
'Apple appAccountToken mismatch -- possible replay',
|
||||
expected=str(user.id),
|
||||
received=app_account_token,
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=False)
|
||||
|
||||
# Detect sandbox transactions -- store actual environment from Apple's response
|
||||
actual_environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT)
|
||||
is_sandbox = actual_environment == 'Sandbox'
|
||||
|
||||
if is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
||||
# Sandbox transaction on a production server (e.g. App Review).
|
||||
# Record it for audit but do NOT credit real balance.
|
||||
logger.info(
|
||||
'Apple sandbox transaction on production -- storing without balance credit',
|
||||
transaction_id=request.transaction_id,
|
||||
product_id=request.product_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
await create_apple_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
transaction_id=request.transaction_id,
|
||||
original_transaction_id=txn_info.get('originalTransactionId'),
|
||||
product_id=request.product_id,
|
||||
bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID),
|
||||
amount_kopeks=amount_kopeks,
|
||||
environment='Sandbox',
|
||||
)
|
||||
except IntegrityError:
|
||||
pass # already stored
|
||||
await db.commit()
|
||||
return ApplePurchaseResponse(success=True)
|
||||
|
||||
# Atomically insert transaction record -- unique constraint on transaction_id
|
||||
# prevents double-spend even under concurrent requests.
|
||||
apple_txn = None
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
apple_txn = await create_apple_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
transaction_id=request.transaction_id,
|
||||
original_transaction_id=txn_info.get('originalTransactionId'),
|
||||
product_id=request.product_id,
|
||||
bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID),
|
||||
amount_kopeks=amount_kopeks,
|
||||
environment=actual_environment,
|
||||
)
|
||||
except IntegrityError:
|
||||
logger.info(
|
||||
'Apple transaction already processed (idempotent)',
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=True)
|
||||
|
||||
# Create financial transaction record
|
||||
transaction = await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f'Пополнение через Apple IAP: {request.product_id}',
|
||||
payment_method=PaymentMethod.APPLE_IAP,
|
||||
external_id=request.transaction_id,
|
||||
is_completed=True,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# FIX 9: Link AppleTransaction to financial Transaction via FK
|
||||
if apple_txn and transaction:
|
||||
apple_txn.transaction_id_fk = transaction.id
|
||||
apple_txn.updated_at = datetime.now(UTC)
|
||||
|
||||
# Lock user row and credit balance
|
||||
user = await lock_user_for_update(db, user)
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
# FIX 10: Update user.updated_at when modifying balance
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = 'Первое пополнение' if was_first_topup else 'Пополнение'
|
||||
|
||||
await db.commit()
|
||||
|
||||
# --- Post-payment side-effects (after atomic commit) ---
|
||||
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
try:
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.APPLE_IAP,
|
||||
external_id=request.transaction_id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка emit_transaction_side_effects Apple IAP', error=error)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(db, user.id, amount_kopeks, bot=None)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Apple IAP', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
# Admin notification + cart auto-purchase
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = create_bot()
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления Apple IAP', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, amount_kopeks, db, bot)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка при работе с сохраненной корзиной Apple IAP', user_id=user.id, error=error)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as error:
|
||||
logger.error('Ошибка создания бота для уведомлений Apple IAP', error=error)
|
||||
|
||||
logger.info(
|
||||
'Apple IAP purchase credited',
|
||||
transaction_id=request.transaction_id,
|
||||
product_id=request.product_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
return ApplePurchaseResponse(success=True)
|
||||
+102
-60
@@ -172,74 +172,112 @@ async def _process_campaign_bonus(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
campaign_slug: str | None,
|
||||
telegram_id: int | None = None,
|
||||
) -> CampaignBonusInfo | None:
|
||||
"""Process campaign bonus for user during auth. Never raises."""
|
||||
"""Process campaign bonus for user during auth. Never raises.
|
||||
|
||||
If ``campaign_slug`` is not provided but ``telegram_id`` is given, the
|
||||
function falls back to Redis ``pending_campaign:{telegram_id}`` -- populated
|
||||
by the bot's /start handler when a user opens an advertising campaign link
|
||||
but then completes registration via the cabinet WebApp (Telegram menu
|
||||
button) instead of the bot dialog. The Redis entry is cleared after a
|
||||
successful consumption attempt.
|
||||
"""
|
||||
pending_campaign_consumed = False
|
||||
if not campaign_slug and telegram_id:
|
||||
try:
|
||||
from app.services.referral_service import get_pending_campaign
|
||||
|
||||
pending = await get_pending_campaign(telegram_id)
|
||||
if pending and pending.get('campaign_slug'):
|
||||
campaign_slug = pending['campaign_slug']
|
||||
pending_campaign_consumed = True
|
||||
logger.info(
|
||||
'Resolved campaign from Redis pending_campaign (cabinet)',
|
||||
telegram_id=telegram_id,
|
||||
campaign_slug=campaign_slug,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to check pending campaign', error=e)
|
||||
|
||||
if not campaign_slug:
|
||||
return None
|
||||
try:
|
||||
campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True)
|
||||
if not campaign:
|
||||
return None
|
||||
try:
|
||||
campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True)
|
||||
if not campaign:
|
||||
return None
|
||||
|
||||
# Skip if user IS the campaign partner — prevent self-referral
|
||||
if campaign.partner_user_id and campaign.partner_user_id == user.id:
|
||||
logger.debug(
|
||||
'Skipping campaign attribution: user is the campaign partner',
|
||||
user_id=user.id,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Lock user row to prevent concurrent bonus application (race condition)
|
||||
await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
|
||||
existing = await get_campaign_registration_by_user(db, user.id)
|
||||
if existing:
|
||||
logger.debug('User already has campaign registration', user_id=user.id)
|
||||
return None
|
||||
|
||||
# Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан)
|
||||
if campaign.partner_user_id and not user.referred_by_id:
|
||||
user.referred_by_id = campaign.partner_user_id
|
||||
await db.flush()
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
|
||||
logger.info(
|
||||
'Referral set from campaign partner',
|
||||
# Skip if user IS the campaign partner — prevent self-referral
|
||||
if campaign.partner_user_id and campaign.partner_user_id == user.id:
|
||||
logger.debug(
|
||||
'Skipping campaign attribution: user is the campaign partner',
|
||||
user_id=user.id,
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Failed to process referral from campaign partner', error=e)
|
||||
return None
|
||||
|
||||
service = AdvertisingCampaignService()
|
||||
result = await service.apply_campaign_bonus(db, user, campaign)
|
||||
if not result.success:
|
||||
return None
|
||||
# Lock user row to prevent concurrent bonus application (race condition)
|
||||
await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
|
||||
# Refresh user to get updated balance after bonus
|
||||
await db.refresh(user)
|
||||
existing = await get_campaign_registration_by_user(db, user.id)
|
||||
if existing:
|
||||
logger.debug('User already has campaign registration', user_id=user.id)
|
||||
return None
|
||||
|
||||
return CampaignBonusInfo(
|
||||
campaign_name=campaign.name,
|
||||
bonus_type=result.bonus_type or campaign.bonus_type,
|
||||
balance_kopeks=result.balance_kopeks,
|
||||
subscription_days=result.subscription_days,
|
||||
tariff_name=result.tariff_name,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug)
|
||||
try:
|
||||
await db.rollback()
|
||||
# Re-fetch user so session stays usable for the caller
|
||||
# Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан)
|
||||
if campaign.partner_user_id and not user.referred_by_id:
|
||||
user.referred_by_id = campaign.partner_user_id
|
||||
await db.flush()
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
|
||||
logger.info(
|
||||
'Referral set from campaign partner',
|
||||
user_id=user.id,
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Failed to process referral from campaign partner', error=e)
|
||||
|
||||
service = AdvertisingCampaignService()
|
||||
result = await service.apply_campaign_bonus(db, user, campaign)
|
||||
if not result.success:
|
||||
return None
|
||||
|
||||
# Refresh user to get updated balance after bonus
|
||||
await db.refresh(user)
|
||||
|
||||
return CampaignBonusInfo(
|
||||
campaign_name=campaign.name,
|
||||
bonus_type=result.bonus_type or campaign.bonus_type,
|
||||
balance_kopeks=result.balance_kopeks,
|
||||
subscription_days=result.subscription_days,
|
||||
tariff_name=result.tariff_name,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to rollback after campaign bonus error', user_id=user.id)
|
||||
return None
|
||||
logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug)
|
||||
try:
|
||||
await db.rollback()
|
||||
# Re-fetch user so session stays usable for the caller
|
||||
await db.refresh(user)
|
||||
except Exception:
|
||||
logger.exception('Failed to rollback after campaign bonus error', user_id=user.id)
|
||||
return None
|
||||
finally:
|
||||
# Clear Redis pending_campaign whenever we consumed it. Done regardless
|
||||
# of success — if processing failed (already applied, race, exception),
|
||||
# we don't want to keep retrying on every subsequent login.
|
||||
if pending_campaign_consumed and telegram_id:
|
||||
try:
|
||||
from app.services.referral_service import clear_pending_campaign
|
||||
|
||||
await clear_pending_campaign(telegram_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _process_referral_code(
|
||||
@@ -584,8 +622,11 @@ async def auth_telegram(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
# Process campaign bonus.
|
||||
# Pass telegram_id so the function can fall back to Redis pending_campaign
|
||||
# if the user came via /start <campaign> in the bot but completed
|
||||
# registration in the WebApp without an explicit campaign_slug.
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=telegram_id)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
@@ -691,8 +732,8 @@ async def auth_telegram_widget(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
# Process campaign bonus (pending_campaign Redis fallback for Telegram Login Widget)
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=request.id)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
@@ -837,7 +878,8 @@ async def auth_telegram_oidc(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
# Process campaign bonus (pending_campaign Redis fallback for Telegram OIDC)
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=telegram_id)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
|
||||
@@ -914,6 +914,99 @@ async def create_topup(
|
||||
detail='Failed to create AuraPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'jupiter':
|
||||
if not settings.is_jupiter_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Jupiter payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_jupiter_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Jupiter payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'donut':
|
||||
if not settings.is_donut_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Donut payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_donut_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Donut payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'lava':
|
||||
if not settings.is_lava_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Lava payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_lava_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Lava payment',
|
||||
)
|
||||
|
||||
else:
|
||||
# For other payment methods, redirect to bot
|
||||
raise HTTPException(
|
||||
@@ -1065,6 +1158,45 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.JUPITER:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'cancelled': ('❌', 'Отменено'),
|
||||
'declined': ('❌', 'Отклонено'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.DONUT:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'created': ('⏳', 'Создано'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'cancelled': ('❌', 'Отменено'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.LAVA:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'created': ('⏳', 'Создано'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'cancel': ('❌', 'Отменено'),
|
||||
'cancelled': ('❌', 'Отменено'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
return '❓', 'Неизвестно'
|
||||
|
||||
|
||||
@@ -1206,15 +1338,20 @@ async def get_latest_payment_by_method(
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
AuraPayPayment,
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
OverpayPayment,
|
||||
Pal24Payment,
|
||||
PayPearPayment,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
RollyPayPayment,
|
||||
SeverPayPayment,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
@@ -1231,6 +1368,11 @@ async def get_latest_payment_by_method(
|
||||
PaymentMethod.FREEKASSA: FreekassaPayment,
|
||||
PaymentMethod.KASSA_AI: KassaAiPayment,
|
||||
PaymentMethod.RIOPAY: RioPayPayment,
|
||||
PaymentMethod.SEVERPAY: SeverPayPayment,
|
||||
PaymentMethod.ROLLYPAY: RollyPayPayment,
|
||||
PaymentMethod.PAYPEAR: PayPearPayment,
|
||||
PaymentMethod.OVERPAY: OverpayPayment,
|
||||
PaymentMethod.AURAPAY: AuraPayPayment,
|
||||
}
|
||||
|
||||
model = model_map.get(payment_method)
|
||||
|
||||
@@ -99,25 +99,35 @@ async def upload_media(
|
||||
bot = create_bot()
|
||||
|
||||
try:
|
||||
# Send with disable_notification to avoid pinging admins — this is just staging
|
||||
if media_type_normalized == 'photo':
|
||||
message = await bot.send_photo(
|
||||
chat_id=target_chat_id,
|
||||
photo=upload,
|
||||
disable_notification=True,
|
||||
)
|
||||
media = message.photo[-1]
|
||||
elif media_type_normalized == 'video':
|
||||
message = await bot.send_video(
|
||||
chat_id=target_chat_id,
|
||||
video=upload,
|
||||
disable_notification=True,
|
||||
)
|
||||
media = message.video
|
||||
else:
|
||||
message = await bot.send_document(
|
||||
chat_id=target_chat_id,
|
||||
document=upload,
|
||||
disable_notification=True,
|
||||
)
|
||||
media = message.document
|
||||
|
||||
# Delete the staging message immediately — file_id persists after deletion
|
||||
try:
|
||||
await bot.delete_message(chat_id=target_chat_id, message_id=message.message_id)
|
||||
except Exception:
|
||||
pass # Best-effort cleanup — file_id is already captured
|
||||
|
||||
media_url = _build_media_url(request, media.file_id)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -144,7 +144,7 @@ async def get_available_polls(
|
||||
selectinload(PollResponse.poll).selectinload(Poll.questions),
|
||||
selectinload(PollResponse.answers),
|
||||
)
|
||||
.order_by(PollResponse.created_at.desc())
|
||||
.order_by(PollResponse.sent_at.desc())
|
||||
)
|
||||
responses = result.scalars().all()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
|
||||
@@ -67,6 +68,29 @@ async def activate_promocode(
|
||||
balance_before_rubles = result.get('balance_before_kopeks', 0) / 100
|
||||
balance_after_rubles = result.get('balance_after_kopeks', 0) / 100
|
||||
|
||||
# Send admin notification (same as bot handler)
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_promocode_activation_notification(
|
||||
db,
|
||||
user,
|
||||
result.get('promocode', {'code': request.code.strip()}),
|
||||
result.get('description', ''),
|
||||
result.get('balance_before_kopeks'),
|
||||
result.get('balance_after_kopeks'),
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return PromocodeActivateResponse(
|
||||
success=True,
|
||||
message='Promo code activated successfully',
|
||||
|
||||
@@ -19,6 +19,7 @@ from .subscription_modules import (
|
||||
devices_router,
|
||||
purchase_router,
|
||||
renewal_router,
|
||||
revoke_router,
|
||||
servers_router,
|
||||
status_router,
|
||||
tariff_switch_router,
|
||||
@@ -50,3 +51,4 @@ router.include_router(servers_router)
|
||||
router.include_router(autopay_router)
|
||||
router.include_router(daily_router)
|
||||
router.include_router(tariff_switch_router)
|
||||
router.include_router(revoke_router)
|
||||
|
||||
@@ -10,6 +10,7 @@ from .devices import router as devices_router
|
||||
from .multi_tariff import router as multi_tariff_router
|
||||
from .purchase import router as purchase_router
|
||||
from .renewal import router as renewal_router
|
||||
from .revoke import router as revoke_router
|
||||
from .servers import router as servers_router
|
||||
from .status import router as status_router
|
||||
from .tariff_switch import router as tariff_switch_router
|
||||
@@ -23,6 +24,7 @@ __all__ = [
|
||||
'multi_tariff_router',
|
||||
'purchase_router',
|
||||
'renewal_router',
|
||||
'revoke_router',
|
||||
'servers_router',
|
||||
'status_router',
|
||||
'tariff_switch_router',
|
||||
|
||||
@@ -49,7 +49,8 @@ async def update_autopay(
|
||||
)
|
||||
|
||||
# Триальные подписки — пробник, автопродление не имеет смысла
|
||||
if subscription.is_trial:
|
||||
# NULL-safe: is_trial can be None in legacy rows — treat as trial
|
||||
if subscription.is_trial is not False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Autopay is not available for trial subscriptions',
|
||||
|
||||
@@ -13,6 +13,7 @@ POST /subscription/devices/save-cart
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -380,7 +381,7 @@ async def purchase_devices(
|
||||
if end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=UTC)
|
||||
|
||||
days_left = max(1, (end_date - now).days)
|
||||
days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400))
|
||||
total_days = 30 # Base period for device price calculation
|
||||
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
@@ -658,7 +659,7 @@ async def save_devices_cart(
|
||||
if end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=UTC)
|
||||
|
||||
days_left = max(1, (end_date - now).days)
|
||||
days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400))
|
||||
total_days = 30
|
||||
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
@@ -772,7 +773,7 @@ async def get_device_price(
|
||||
if end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=UTC)
|
||||
|
||||
days_left = max(1, (end_date - now).days)
|
||||
days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400))
|
||||
total_days = 30
|
||||
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
@@ -827,6 +828,9 @@ async def get_device_price(
|
||||
response['discount_percent'] = devices_discount_percent
|
||||
response['discount_kopeks'] = discount_value
|
||||
response['base_total_price_kopeks'] = base_total_price
|
||||
response['original_price_per_device_kopeks'] = (
|
||||
base_total_price // devices if devices > 0 else 0
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -895,8 +895,14 @@ async def purchase_tariff(
|
||||
except Exception as trial_err:
|
||||
logger.warning('Failed to disable trial on RemnaWave', error=trial_err, trial_id=trial_sub.id)
|
||||
try:
|
||||
if subscription.remnawave_uuid:
|
||||
# Existing subscription with Remnawave user — update it
|
||||
# Mirror the bot handler logic: in single-tariff mode, check user.remnawave_uuid
|
||||
# (webhook clears it on panel deletion), not subscription.remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(user, 'remnawave_uuid', None)
|
||||
|
||||
if not _should_create:
|
||||
await service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
@@ -905,7 +911,6 @@ async def purchase_tariff(
|
||||
sync_squads=True,
|
||||
)
|
||||
else:
|
||||
# New subscription — create new Remnawave user
|
||||
await service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
@@ -919,7 +924,7 @@ async def purchase_tariff(
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='create' if not subscription.remnawave_uuid else 'update',
|
||||
action='create' if _should_create else 'update',
|
||||
)
|
||||
|
||||
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Cabinet API endpoint for subscription reissue.
|
||||
|
||||
POST /subscription/revoke
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/revoke')
|
||||
async def revoke_subscription(
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict:
|
||||
"""Revoke and reissue subscription (generate new connection link)."""
|
||||
if not settings.is_subscription_revoke_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Subscription reissue is not available',
|
||||
)
|
||||
|
||||
# Reload user from current session
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
fresh_user = await get_user_by_id(db, user.id)
|
||||
if not fresh_user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
|
||||
|
||||
subscription = await resolve_subscription(db, fresh_user, subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Subscription not found')
|
||||
|
||||
if not subscription.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Only active subscriptions can be reissued',
|
||||
)
|
||||
|
||||
# Check cooldown
|
||||
if subscription.last_revoke_at:
|
||||
elapsed = (datetime.now(UTC) - subscription.last_revoke_at).total_seconds()
|
||||
cooldown = settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS
|
||||
if elapsed < cooldown:
|
||||
remaining = int(cooldown - elapsed)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f'Cooldown active. Try again in {remaining} seconds.',
|
||||
headers={'Retry-After': str(remaining)},
|
||||
)
|
||||
|
||||
# Execute revoke
|
||||
sub_service = SubscriptionService()
|
||||
new_url = await sub_service.revoke_subscription(db, subscription)
|
||||
|
||||
if not new_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reissue subscription',
|
||||
)
|
||||
|
||||
# Update cooldown timestamp
|
||||
subscription.last_revoke_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Subscription revoked via cabinet API',
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'cooldown_seconds': settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS,
|
||||
}
|
||||
@@ -9,6 +9,7 @@ POST /subscription/traffic/save-cart
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -478,7 +479,7 @@ async def save_traffic_cart(
|
||||
from app.utils.pricing_utils import calculate_prorated_price as _calc_prorated
|
||||
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription.end_date - now).days)
|
||||
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
|
||||
prorated_price, _ = _calc_prorated(
|
||||
base_price_kopeks,
|
||||
subscription.end_date,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Apple In-App Purchase schemas for cabinet."""
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class ApplePurchaseRequest(BaseModel):
|
||||
"""Request to verify and credit an Apple IAP transaction."""
|
||||
|
||||
product_id: str = Field(..., description='Apple product ID (e.g. com.bitnet.vpnclient.topup.100)')
|
||||
transaction_id: str = Field(..., min_length=1, max_length=64, description='Apple StoreKit transaction ID')
|
||||
|
||||
@field_validator('transaction_id')
|
||||
@classmethod
|
||||
def transaction_id_must_be_numeric(cls, v: str) -> str:
|
||||
if not v.isdigit():
|
||||
raise ValueError('transaction_id must contain only digits')
|
||||
return v
|
||||
|
||||
|
||||
class ApplePurchaseResponse(BaseModel):
|
||||
"""Response indicating whether the purchase was successfully credited."""
|
||||
|
||||
success: bool
|
||||
@@ -29,6 +29,7 @@ class BulkActionParams(BaseModel):
|
||||
promo_group_id: int | None = None
|
||||
device_limit: int | None = Field(None, ge=1, le=50)
|
||||
delete_from_panel: bool = Field(default=True)
|
||||
force_delete_active_paid: bool = Field(default=False)
|
||||
|
||||
|
||||
class BulkSubscriptionInfo(BaseModel):
|
||||
|
||||
@@ -89,6 +89,7 @@ class SubscriptionListItem(BaseModel):
|
||||
tariff_id: int | None = None
|
||||
tariff_name: str | None = None
|
||||
status: str
|
||||
is_trial: bool = False
|
||||
end_date: datetime | None = None
|
||||
days_remaining: int = 0
|
||||
traffic_used_gb: float = 0
|
||||
|
||||
@@ -45,18 +45,26 @@ class EmailService:
|
||||
def use_tls(self) -> bool:
|
||||
return settings.SMTP_USE_TLS
|
||||
|
||||
@property
|
||||
def use_ssl(self) -> bool:
|
||||
# Port 465 always implies implicit TLS (SMTPS, RFC 8314).
|
||||
return settings.SMTP_USE_SSL or self.port == 465
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if SMTP is properly configured."""
|
||||
return settings.is_smtp_configured()
|
||||
|
||||
def _get_smtp_connection(self) -> smtplib.SMTP:
|
||||
"""Create and return SMTP connection."""
|
||||
smtp = smtplib.SMTP(self.host, self.port, timeout=30)
|
||||
smtp.ehlo()
|
||||
|
||||
if self.use_tls:
|
||||
smtp.starttls()
|
||||
if self.use_ssl:
|
||||
smtp: smtplib.SMTP = smtplib.SMTP_SSL(self.host, self.port, timeout=30)
|
||||
smtp.ehlo()
|
||||
else:
|
||||
smtp = smtplib.SMTP(self.host, self.port, timeout=30)
|
||||
smtp.ehlo()
|
||||
if self.use_tls:
|
||||
smtp.starttls()
|
||||
smtp.ehlo()
|
||||
|
||||
# Only attempt login if credentials are provided AND server supports AUTH
|
||||
if self.user and self.password:
|
||||
|
||||
+360
-36
@@ -1,5 +1,3 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
@@ -67,6 +65,18 @@ class Settings(BaseSettings):
|
||||
ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID: int | None = None # Промокоды, кампании, промогруппы
|
||||
ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID: int | None = None # Партнёрки, выводы, админ-действия
|
||||
|
||||
# Per-category enable/disable (default True for backwards compatibility)
|
||||
ADMIN_NOTIFICATIONS_PURCHASES_ENABLED: bool = True
|
||||
ADMIN_NOTIFICATIONS_RENEWALS_ENABLED: bool = True
|
||||
ADMIN_NOTIFICATIONS_TRIALS_ENABLED: bool = True
|
||||
ADMIN_NOTIFICATIONS_BALANCE_ENABLED: bool = True
|
||||
ADMIN_NOTIFICATIONS_ADDONS_ENABLED: bool = True
|
||||
ADMIN_NOTIFICATIONS_INFRASTRUCTURE_ENABLED: bool = True
|
||||
ADMIN_NOTIFICATIONS_ERRORS_ENABLED: bool = True
|
||||
ADMIN_NOTIFICATIONS_PROMO_ENABLED: bool = True
|
||||
ADMIN_NOTIFICATIONS_PARTNERS_ENABLED: bool = True
|
||||
ADMIN_NOTIFICATIONS_TICKETS_ENABLED: bool = True
|
||||
|
||||
# Настройки очереди чеков NaloGO
|
||||
NALOGO_QUEUE_CHECK_INTERVAL: int = 600 # Интервал проверки очереди (секунды, 10 мин)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
|
||||
@@ -276,6 +286,10 @@ class Settings(BaseSettings):
|
||||
|
||||
DISPOSABLE_EMAIL_CHECK_ENABLED: bool = True
|
||||
|
||||
# Настройки перевыпуска подписки (revoke + regenerate link)
|
||||
SUBSCRIPTION_REVOKE_ENABLED: bool = True
|
||||
SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS: int = 900 # 15 minutes
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
@@ -606,6 +620,19 @@ class Settings(BaseSettings):
|
||||
SEVERPAY_RETURN_URL: str | None = None
|
||||
SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320
|
||||
|
||||
# Apple In-App Purchase
|
||||
APPLE_IAP_ENABLED: bool = False
|
||||
APPLE_IAP_KEY_ID: str | None = None
|
||||
APPLE_IAP_ISSUER_ID: str | None = None
|
||||
APPLE_IAP_BUNDLE_ID: str = 'com.app.client'
|
||||
APPLE_IAP_PRIVATE_KEY: str | None = None # .p8 key contents (PEM)
|
||||
APPLE_IAP_PRIVATE_KEY_PATH: str | None = None # Alternative: path to .p8 file
|
||||
APPLE_IAP_ENVIRONMENT: str = 'Production' # 'Sandbox' or 'Production'
|
||||
APPLE_IAP_WEBHOOK_PATH: str = '/apple-iap-webhook'
|
||||
APPLE_IAP_PRODUCTS: str = (
|
||||
'{"com.app.client.topup.100":10000,"com.app.client.topup.300":30000,"com.app.client.topup.500":50000}'
|
||||
)
|
||||
|
||||
# PayPear (paypear.ru)
|
||||
PAYPEAR_ENABLED: bool = False
|
||||
PAYPEAR_SHOP_ID: str | None = None
|
||||
@@ -658,6 +685,108 @@ class Settings(BaseSettings):
|
||||
AURAPAY_WEBHOOK_PATH: str = '/aurapay-webhook'
|
||||
AURAPAY_RETURN_URL: str | None = None
|
||||
AURAPAY_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
AURAPAY_SBP_ENABLED: bool = False
|
||||
AURAPAY_SBP_DISPLAY_NAME: str = 'СБП (AuraPay)'
|
||||
AURAPAY_CARD_ENABLED: bool = False
|
||||
AURAPAY_CARD_DISPLAY_NAME: str = 'Карта (AuraPay)'
|
||||
|
||||
# Antilopay (lk.antilopay.com)
|
||||
ANTILOPAY_ENABLED: bool = False
|
||||
ANTILOPAY_SECRET_ID: str | None = None
|
||||
ANTILOPAY_PRIVATE_KEY: str | None = None
|
||||
ANTILOPAY_PUBLIC_KEY: str | None = None
|
||||
ANTILOPAY_PROJECT_ID: str | None = None
|
||||
ANTILOPAY_DISPLAY_NAME: str = 'Antilopay'
|
||||
ANTILOPAY_PRODUCT_NAME: str = 'VPN подписка'
|
||||
ANTILOPAY_PRODUCT_TYPE: str = 'services'
|
||||
ANTILOPAY_CURRENCY: str = 'RUB'
|
||||
ANTILOPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
ANTILOPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
ANTILOPAY_WEBHOOK_PATH: str = '/antilopay-webhook'
|
||||
ANTILOPAY_RETURN_URL: str | None = None
|
||||
ANTILOPAY_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
ANTILOPAY_SBP_ENABLED: bool = False
|
||||
ANTILOPAY_SBP_DISPLAY_NAME: str = 'СБП (Antilopay)'
|
||||
ANTILOPAY_CARD_ENABLED: bool = False
|
||||
ANTILOPAY_CARD_DISPLAY_NAME: str = 'Карта (Antilopay)'
|
||||
ANTILOPAY_SBERPAY_ENABLED: bool = False
|
||||
ANTILOPAY_SBERPAY_DISPLAY_NAME: str = 'SberPay (Antilopay)'
|
||||
|
||||
# Jupiter (FPGate P2P v2.1, app.juppiter.tech)
|
||||
JUPITER_ENABLED: bool = False
|
||||
JUPITER_TOKEN: str | None = None
|
||||
JUPITER_SECRET: str | None = None
|
||||
JUPITER_BASE_URL: str = 'https://app.juppiter.tech'
|
||||
JUPITER_METHOD_ID: str | None = None
|
||||
JUPITER_METHOD_DESCRIPTION: str = 'SBP'
|
||||
JUPITER_DISPLAY_NAME: str = 'Jupiter'
|
||||
JUPITER_CURRENCY: str = 'RUB'
|
||||
JUPITER_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
JUPITER_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
JUPITER_WEBHOOK_PATH: str = '/jupiter-webhook'
|
||||
JUPITER_RETURN_URL: str | None = None
|
||||
JUPITER_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
JUPITER_FALLBACK_EMAIL: str = 'user@vpn.bot'
|
||||
JUPITER_FALLBACK_PHONE: str = '0000000000'
|
||||
JUPITER_FALLBACK_NAME: str = 'User'
|
||||
JUPITER_SBP_ENABLED: bool = False
|
||||
JUPITER_SBP_DISPLAY_NAME: str = 'СБП (Jupiter)'
|
||||
|
||||
# Donut (Donut P2P, gw.donut.business)
|
||||
DONUT_ENABLED: bool = False
|
||||
DONUT_TOKEN: str | None = None
|
||||
DONUT_SECRET: str | None = None
|
||||
DONUT_BASE_URL: str = 'https://gw.donut.business'
|
||||
DONUT_METHOD_ID: str | None = None
|
||||
DONUT_DISPLAY_NAME: str = 'Donut'
|
||||
DONUT_CURRENCY: str = 'RUB'
|
||||
DONUT_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
DONUT_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
DONUT_WEBHOOK_PATH: str = '/donut-webhook'
|
||||
DONUT_RETURN_URL: str | None = None
|
||||
DONUT_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
# Sub-методы Donut (description в PayIn запросе)
|
||||
DONUT_CARD_ENABLED: bool = False
|
||||
DONUT_CARD_DISPLAY_NAME: str = 'Карта (Donut)'
|
||||
DONUT_SBP_ENABLED: bool = False
|
||||
DONUT_SBP_DISPLAY_NAME: str = 'СБП (Donut)'
|
||||
DONUT_SBP_QR_ENABLED: bool = False
|
||||
DONUT_SBP_QR_DISPLAY_NAME: str = 'СБП QR (Donut)'
|
||||
|
||||
# Lava (Lava Business API, gate.lava.ru)
|
||||
LAVA_ENABLED: bool = False
|
||||
LAVA_BASE_URL: str = 'https://gate.lava.ru'
|
||||
LAVA_SHOP_ID: str | None = None # UUID проекта
|
||||
LAVA_SECRET_KEY: str | None = None # secret_key — для подписи запросов
|
||||
LAVA_WEBHOOK_SECRET: str | None = None # secret_key_2 — для проверки подписи webhook
|
||||
LAVA_DISPLAY_NAME: str = 'Lava'
|
||||
LAVA_CURRENCY: str = 'RUB'
|
||||
LAVA_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
LAVA_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
LAVA_WEBHOOK_PATH: str = '/lava-webhook'
|
||||
LAVA_RETURN_URL: str | None = None
|
||||
LAVA_PAYMENT_LIFETIME_MINUTES: int = 60 # макс 7200 минут (5 дней)
|
||||
# Sub-методы Lava (фильтр через includeService/excludeService на стороне Lava)
|
||||
LAVA_CARD_ENABLED: bool = False
|
||||
LAVA_CARD_DISPLAY_NAME: str = 'Карта (Lava)'
|
||||
LAVA_SBP_ENABLED: bool = False
|
||||
LAVA_SBP_DISPLAY_NAME: str = 'СБП (Lava)'
|
||||
|
||||
# Etoplatezhi (paymentpage.etoplatezhi.ru)
|
||||
ETOPLATEZHI_ENABLED: bool = False
|
||||
ETOPLATEZHI_PROJECT_ID: int | None = None
|
||||
ETOPLATEZHI_SECRET_KEY: str | None = None
|
||||
ETOPLATEZHI_DISPLAY_NAME: str = 'Etoplatezhi'
|
||||
ETOPLATEZHI_CURRENCY: str = 'RUB'
|
||||
ETOPLATEZHI_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
ETOPLATEZHI_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
ETOPLATEZHI_WEBHOOK_PATH: str = '/etoplatezhi-webhook'
|
||||
ETOPLATEZHI_RETURN_URL: str | None = None
|
||||
ETOPLATEZHI_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
ETOPLATEZHI_SBP_ENABLED: bool = False
|
||||
ETOPLATEZHI_SBP_DISPLAY_NAME: str = 'СБП (Etoplatezhi)'
|
||||
ETOPLATEZHI_CARD_ENABLED: bool = False
|
||||
ETOPLATEZHI_CARD_DISPLAY_NAME: str = 'Карта (Etoplatezhi)'
|
||||
|
||||
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
|
||||
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
|
||||
@@ -844,9 +973,6 @@ class Settings(BaseSettings):
|
||||
BACKUP_SEND_TOPIC_ID: int | None = None
|
||||
BACKUP_ARCHIVE_PASSWORD: str | None = None
|
||||
|
||||
EXTERNAL_ADMIN_TOKEN: str | None = None
|
||||
EXTERNAL_ADMIN_TOKEN_BOT_ID: int | None = None
|
||||
|
||||
# Cabinet (Personal Account) settings
|
||||
CABINET_ENABLED: bool = False
|
||||
CABINET_JWT_SECRET: str | None = None
|
||||
@@ -888,6 +1014,8 @@ class Settings(BaseSettings):
|
||||
SMTP_FROM_EMAIL: str | None = None
|
||||
SMTP_FROM_NAME: str = 'VPN Service'
|
||||
SMTP_USE_TLS: bool = True
|
||||
# Implicit TLS (SMTPS) — required for port 465. Auto-enabled when SMTP_PORT == 465.
|
||||
SMTP_USE_SSL: bool = False
|
||||
|
||||
# Ban System Integration (BedolagaBan monitoring)
|
||||
BAN_SYSTEM_ENABLED: bool = False
|
||||
@@ -1653,37 +1781,6 @@ class Settings(BaseSettings):
|
||||
def get_app_config_cache_ttl(self) -> int:
|
||||
return self.APP_CONFIG_CACHE_TTL
|
||||
|
||||
def build_external_admin_token(self, bot_username: str) -> str:
|
||||
"""Генерирует детерминированный и криптографически стойкий токен внешней админки."""
|
||||
normalized = (bot_username or '').strip().lstrip('@').lower()
|
||||
if not normalized:
|
||||
raise ValueError('Bot username is required to build external admin token')
|
||||
|
||||
secret = (self.BOT_TOKEN or '').strip()
|
||||
if not secret:
|
||||
raise ValueError('Bot token is required to build external admin token')
|
||||
|
||||
digest = hmac.new(
|
||||
key=secret.encode('utf-8'),
|
||||
msg=f'remnawave.external_admin::{normalized}'.encode(),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
return digest[:48]
|
||||
|
||||
def get_external_admin_token(self) -> str | None:
|
||||
token = (self.EXTERNAL_ADMIN_TOKEN or '').strip()
|
||||
return token or None
|
||||
|
||||
def get_external_admin_bot_id(self) -> int | None:
|
||||
try:
|
||||
return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None
|
||||
except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений
|
||||
logger.warning(
|
||||
'Некорректный идентификатор бота для внешней админки',
|
||||
EXTERNAL_ADMIN_TOKEN_BOT_ID=self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
|
||||
)
|
||||
return None
|
||||
|
||||
def is_traffic_selectable(self) -> bool:
|
||||
return self.TRAFFIC_SELECTION_MODE.lower() == 'selectable'
|
||||
|
||||
@@ -1772,6 +1869,10 @@ class Settings(BaseSettings):
|
||||
def get_disabled_mode_device_limit(self) -> int | None:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_subscription_revoke_enabled(self) -> bool:
|
||||
"""Проверяет, включен ли перевыпуск подписки."""
|
||||
return self.SUBSCRIPTION_REVOKE_ENABLED
|
||||
|
||||
def is_multi_tariff_enabled(self) -> bool:
|
||||
"""Проверяет, включен ли мультитарифный режим."""
|
||||
return self.MULTI_TARIFF_ENABLED and self.SALES_MODE == 'tariffs'
|
||||
@@ -2051,6 +2152,34 @@ class Settings(BaseSettings):
|
||||
def get_severpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_severpay_display_name())
|
||||
|
||||
def is_apple_iap_enabled(self) -> bool:
|
||||
return (
|
||||
self.APPLE_IAP_ENABLED
|
||||
and self.APPLE_IAP_KEY_ID is not None
|
||||
and self.APPLE_IAP_ISSUER_ID is not None
|
||||
and (self.APPLE_IAP_PRIVATE_KEY is not None or self.APPLE_IAP_PRIVATE_KEY_PATH is not None)
|
||||
)
|
||||
|
||||
def get_apple_iap_products(self) -> dict[str, int]:
|
||||
"""Return mapping of Apple product ID -> kopeks amount."""
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
return _json.loads(self.APPLE_IAP_PRODUCTS)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_apple_iap_private_key(self) -> str | None:
|
||||
"""Return the .p8 private key contents."""
|
||||
if self.APPLE_IAP_PRIVATE_KEY:
|
||||
return self.APPLE_IAP_PRIVATE_KEY
|
||||
if self.APPLE_IAP_PRIVATE_KEY_PATH:
|
||||
try:
|
||||
return Path(self.APPLE_IAP_PRIVATE_KEY_PATH).read_text().strip()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def is_paypear_enabled(self) -> bool:
|
||||
return self.PAYPEAR_ENABLED and self.PAYPEAR_SHOP_ID is not None and self.PAYPEAR_SECRET_KEY is not None
|
||||
|
||||
@@ -2101,6 +2230,201 @@ class Settings(BaseSettings):
|
||||
def get_aurapay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_aurapay_display_name())
|
||||
|
||||
def is_aurapay_sbp_enabled(self) -> bool:
|
||||
return self.AURAPAY_SBP_ENABLED and self.is_aurapay_enabled()
|
||||
|
||||
def get_aurapay_sbp_display_name(self) -> str:
|
||||
name = (self.AURAPAY_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (AuraPay)'
|
||||
|
||||
def get_aurapay_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_aurapay_sbp_display_name())
|
||||
|
||||
def is_aurapay_card_enabled(self) -> bool:
|
||||
return self.AURAPAY_CARD_ENABLED and self.is_aurapay_enabled()
|
||||
|
||||
def get_aurapay_card_display_name(self) -> str:
|
||||
name = (self.AURAPAY_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта (AuraPay)'
|
||||
|
||||
def get_aurapay_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_aurapay_card_display_name())
|
||||
|
||||
def is_antilopay_enabled(self) -> bool:
|
||||
return (
|
||||
self.ANTILOPAY_ENABLED
|
||||
and self.ANTILOPAY_SECRET_ID is not None
|
||||
and self.ANTILOPAY_PRIVATE_KEY is not None
|
||||
and self.ANTILOPAY_PUBLIC_KEY is not None
|
||||
and self.ANTILOPAY_PROJECT_ID is not None
|
||||
)
|
||||
|
||||
def get_antilopay_display_name(self) -> str:
|
||||
name = (self.ANTILOPAY_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Antilopay'
|
||||
|
||||
def get_antilopay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_antilopay_display_name())
|
||||
|
||||
def is_antilopay_sbp_enabled(self) -> bool:
|
||||
return self.ANTILOPAY_SBP_ENABLED and self.is_antilopay_enabled()
|
||||
|
||||
def get_antilopay_sbp_display_name(self) -> str:
|
||||
name = (self.ANTILOPAY_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (Antilopay)'
|
||||
|
||||
def get_antilopay_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_antilopay_sbp_display_name())
|
||||
|
||||
def is_antilopay_card_enabled(self) -> bool:
|
||||
return self.ANTILOPAY_CARD_ENABLED and self.is_antilopay_enabled()
|
||||
|
||||
def get_antilopay_card_display_name(self) -> str:
|
||||
name = (self.ANTILOPAY_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта (Antilopay)'
|
||||
|
||||
def get_antilopay_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_antilopay_card_display_name())
|
||||
|
||||
def is_antilopay_sberpay_enabled(self) -> bool:
|
||||
return self.ANTILOPAY_SBERPAY_ENABLED and self.is_antilopay_enabled()
|
||||
|
||||
def get_antilopay_sberpay_display_name(self) -> str:
|
||||
name = (self.ANTILOPAY_SBERPAY_DISPLAY_NAME or '').strip()
|
||||
return name or 'SberPay (Antilopay)'
|
||||
|
||||
def get_antilopay_sberpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_antilopay_sberpay_display_name())
|
||||
|
||||
def is_jupiter_enabled(self) -> bool:
|
||||
return self.JUPITER_ENABLED and self.JUPITER_TOKEN is not None and self.JUPITER_SECRET is not None
|
||||
|
||||
def get_jupiter_display_name(self) -> str:
|
||||
name = (self.JUPITER_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Jupiter'
|
||||
|
||||
def get_jupiter_display_name_html(self) -> str:
|
||||
return html.escape(self.get_jupiter_display_name())
|
||||
|
||||
def is_jupiter_sbp_enabled(self) -> bool:
|
||||
return self.JUPITER_SBP_ENABLED and self.is_jupiter_enabled()
|
||||
|
||||
def get_jupiter_sbp_display_name(self) -> str:
|
||||
name = (self.JUPITER_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (Jupiter)'
|
||||
|
||||
def get_jupiter_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_jupiter_sbp_display_name())
|
||||
|
||||
def is_donut_enabled(self) -> bool:
|
||||
return self.DONUT_ENABLED and self.DONUT_TOKEN is not None and self.DONUT_SECRET is not None
|
||||
|
||||
def get_donut_display_name(self) -> str:
|
||||
name = (self.DONUT_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Donut'
|
||||
|
||||
def get_donut_display_name_html(self) -> str:
|
||||
return html.escape(self.get_donut_display_name())
|
||||
|
||||
def is_donut_card_enabled(self) -> bool:
|
||||
return self.DONUT_CARD_ENABLED and self.is_donut_enabled()
|
||||
|
||||
def get_donut_card_display_name(self) -> str:
|
||||
name = (self.DONUT_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта (Donut)'
|
||||
|
||||
def get_donut_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_donut_card_display_name())
|
||||
|
||||
def is_donut_sbp_enabled(self) -> bool:
|
||||
return self.DONUT_SBP_ENABLED and self.is_donut_enabled()
|
||||
|
||||
def get_donut_sbp_display_name(self) -> str:
|
||||
name = (self.DONUT_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (Donut)'
|
||||
|
||||
def get_donut_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_donut_sbp_display_name())
|
||||
|
||||
def is_donut_sbp_qr_enabled(self) -> bool:
|
||||
return self.DONUT_SBP_QR_ENABLED and self.is_donut_enabled()
|
||||
|
||||
def get_donut_sbp_qr_display_name(self) -> str:
|
||||
name = (self.DONUT_SBP_QR_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП QR (Donut)'
|
||||
|
||||
def get_donut_sbp_qr_display_name_html(self) -> str:
|
||||
return html.escape(self.get_donut_sbp_qr_display_name())
|
||||
|
||||
def is_lava_enabled(self) -> bool:
|
||||
return (
|
||||
self.LAVA_ENABLED
|
||||
and self.LAVA_SHOP_ID is not None
|
||||
and self.LAVA_SECRET_KEY is not None
|
||||
and self.LAVA_WEBHOOK_SECRET is not None
|
||||
)
|
||||
|
||||
def get_lava_display_name(self) -> str:
|
||||
name = (self.LAVA_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Lava'
|
||||
|
||||
def get_lava_display_name_html(self) -> str:
|
||||
return html.escape(self.get_lava_display_name())
|
||||
|
||||
def is_lava_card_enabled(self) -> bool:
|
||||
return self.LAVA_CARD_ENABLED and self.is_lava_enabled()
|
||||
|
||||
def get_lava_card_display_name(self) -> str:
|
||||
name = (self.LAVA_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта (Lava)'
|
||||
|
||||
def get_lava_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_lava_card_display_name())
|
||||
|
||||
def is_lava_sbp_enabled(self) -> bool:
|
||||
return self.LAVA_SBP_ENABLED and self.is_lava_enabled()
|
||||
|
||||
def get_lava_sbp_display_name(self) -> str:
|
||||
name = (self.LAVA_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (Lava)'
|
||||
|
||||
def get_lava_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_lava_sbp_display_name())
|
||||
|
||||
def is_etoplatezhi_enabled(self) -> bool:
|
||||
return (
|
||||
self.ETOPLATEZHI_ENABLED
|
||||
and self.ETOPLATEZHI_PROJECT_ID is not None
|
||||
and self.ETOPLATEZHI_SECRET_KEY is not None
|
||||
)
|
||||
|
||||
def get_etoplatezhi_display_name(self) -> str:
|
||||
name = (self.ETOPLATEZHI_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Etoplatezhi'
|
||||
|
||||
def get_etoplatezhi_display_name_html(self) -> str:
|
||||
return html.escape(self.get_etoplatezhi_display_name())
|
||||
|
||||
def is_etoplatezhi_sbp_enabled(self) -> bool:
|
||||
return self.ETOPLATEZHI_SBP_ENABLED and self.is_etoplatezhi_enabled()
|
||||
|
||||
def get_etoplatezhi_sbp_display_name(self) -> str:
|
||||
name = (self.ETOPLATEZHI_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (Etoplatezhi)'
|
||||
|
||||
def get_etoplatezhi_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_etoplatezhi_sbp_display_name())
|
||||
|
||||
def is_etoplatezhi_card_enabled(self) -> bool:
|
||||
return self.ETOPLATEZHI_CARD_ENABLED and self.is_etoplatezhi_enabled()
|
||||
|
||||
def get_etoplatezhi_card_display_name(self) -> str:
|
||||
name = (self.ETOPLATEZHI_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта (Etoplatezhi)'
|
||||
|
||||
def get_etoplatezhi_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_etoplatezhi_card_display_name())
|
||||
|
||||
def is_kassa_ai_sbp_enabled(self) -> bool:
|
||||
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""CRUD операции для платежей Antilopay."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import AntilopayPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_antilopay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
antilopay_payment_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> AntilopayPayment:
|
||||
"""Создает запись о платеже Antilopay."""
|
||||
payment = AntilopayPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
antilopay_payment_id=antilopay_payment_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж Antilopay', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_order_id(db: AsyncSession, order_id: str) -> AntilopayPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(AntilopayPayment).where(AntilopayPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_invoice_id(db: AsyncSession, antilopay_payment_id: str) -> AntilopayPayment | None:
|
||||
"""Получает платеж по ID от Antilopay."""
|
||||
result = await db.execute(
|
||||
select(AntilopayPayment).where(AntilopayPayment.antilopay_payment_id == antilopay_payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_id(db: AsyncSession, payment_id: int) -> AntilopayPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(AntilopayPayment).where(AntilopayPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> AntilopayPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(AntilopayPayment)
|
||||
.where(AntilopayPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_antilopay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: AntilopayPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
antilopay_payment_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> AntilopayPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if antilopay_payment_id is not None:
|
||||
payment.antilopay_payment_id = antilopay_payment_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлен статус платежа Antilopay',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_antilopay_payments(db: AsyncSession, user_id: int) -> list[AntilopayPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(AntilopayPayment).where(
|
||||
AntilopayPayment.user_id == user_id,
|
||||
AntilopayPayment.status == 'pending',
|
||||
AntilopayPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_antilopay_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[AntilopayPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(AntilopayPayment).where(
|
||||
AntilopayPayment.status == 'pending',
|
||||
AntilopayPayment.is_paid == False,
|
||||
AntilopayPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_antilopay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: AntilopayPayment,
|
||||
transaction_id: int,
|
||||
) -> AntilopayPayment:
|
||||
"""Связывает платеж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -0,0 +1,83 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import AppleTransaction
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_apple_transaction(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
transaction_id: str,
|
||||
product_id: str,
|
||||
bundle_id: str,
|
||||
amount_kopeks: int,
|
||||
environment: str,
|
||||
original_transaction_id: str | None = None,
|
||||
transaction_id_fk: int | None = None,
|
||||
) -> AppleTransaction:
|
||||
apple_txn = AppleTransaction(
|
||||
user_id=user_id,
|
||||
transaction_id=transaction_id,
|
||||
original_transaction_id=original_transaction_id,
|
||||
product_id=product_id,
|
||||
bundle_id=bundle_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
environment=environment,
|
||||
status='verified',
|
||||
is_paid=True,
|
||||
paid_at=datetime.now(UTC),
|
||||
transaction_id_fk=transaction_id_fk,
|
||||
)
|
||||
|
||||
db.add(apple_txn)
|
||||
await db.flush()
|
||||
await db.refresh(apple_txn)
|
||||
|
||||
logger.info(
|
||||
'Создана Apple транзакция',
|
||||
transaction_id=transaction_id,
|
||||
product_id=product_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user_id,
|
||||
)
|
||||
return apple_txn
|
||||
|
||||
|
||||
async def get_apple_transaction_by_transaction_id(db: AsyncSession, transaction_id: str) -> AppleTransaction | None:
|
||||
result = await db.execute(select(AppleTransaction).where(AppleTransaction.transaction_id == transaction_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_apple_transaction_by_transaction_id_for_update(
|
||||
db: AsyncSession, transaction_id: str
|
||||
) -> AppleTransaction | None:
|
||||
"""Get apple transaction with FOR UPDATE lock for safe concurrent access."""
|
||||
result = await db.execute(
|
||||
select(AppleTransaction).where(AppleTransaction.transaction_id == transaction_id).with_for_update()
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def mark_apple_transaction_refunded(db: AsyncSession, transaction_id: str) -> AppleTransaction | None:
|
||||
"""Mark an Apple transaction as refunded. Returns the transaction or None if not found."""
|
||||
apple_txn = await get_apple_transaction_by_transaction_id(db, transaction_id)
|
||||
if not apple_txn:
|
||||
return None
|
||||
|
||||
apple_txn.status = 'refunded'
|
||||
apple_txn.refunded_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(apple_txn)
|
||||
|
||||
logger.info(
|
||||
'Apple транзакция помечена как возврат',
|
||||
transaction_id=transaction_id,
|
||||
user_id=apple_txn.user_id,
|
||||
)
|
||||
return apple_txn
|
||||
@@ -0,0 +1,155 @@
|
||||
"""CRUD операции для платежей Donut (Donut P2P)."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import DonutPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_donut_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
donut_transaction_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> DonutPayment:
|
||||
"""Создаёт запись о платеже Donut."""
|
||||
payment = DonutPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
donut_transaction_id=donut_transaction_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж Donut', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_donut_payment_by_order_id(db: AsyncSession, order_id: str) -> DonutPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(DonutPayment).where(DonutPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_donut_payment_by_invoice_id(db: AsyncSession, donut_transaction_id: str) -> DonutPayment | None:
|
||||
"""Получает платёж по transaction_id, выданному Donut."""
|
||||
result = await db.execute(select(DonutPayment).where(DonutPayment.donut_transaction_id == donut_transaction_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_donut_payment_by_id(db: AsyncSession, payment_id: int) -> DonutPayment | None:
|
||||
"""Получает платеж по локальному ID."""
|
||||
result = await db.execute(select(DonutPayment).where(DonutPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_donut_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> DonutPayment | None:
|
||||
"""Получает платёж с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(DonutPayment)
|
||||
.where(DonutPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_donut_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: DonutPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
donut_transaction_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> DonutPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if donut_transaction_id is not None:
|
||||
payment.donut_transaction_id = donut_transaction_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлён статус платежа Donut',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_donut_payments(db: AsyncSession, user_id: int) -> list[DonutPayment]:
|
||||
"""Возвращает незавершённые платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(DonutPayment).where(
|
||||
DonutPayment.user_id == user_id,
|
||||
DonutPayment.status == 'pending',
|
||||
DonutPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_donut_payments(db: AsyncSession) -> list[DonutPayment]:
|
||||
"""Возвращает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(DonutPayment).where(
|
||||
DonutPayment.status == 'pending',
|
||||
DonutPayment.is_paid == False,
|
||||
DonutPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_donut_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: DonutPayment,
|
||||
transaction_id: int,
|
||||
) -> DonutPayment:
|
||||
"""Связывает платёж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -0,0 +1,161 @@
|
||||
"""CRUD операции для платежей Etoplatezhi."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import EtoplatezhiPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_etoplatezhi_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
etoplatezhi_payment_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> EtoplatezhiPayment:
|
||||
"""Создает запись о платеже Etoplatezhi."""
|
||||
payment = EtoplatezhiPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
etoplatezhi_payment_id=etoplatezhi_payment_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж Etoplatezhi', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_order_id(db: AsyncSession, order_id: str) -> EtoplatezhiPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(EtoplatezhiPayment).where(EtoplatezhiPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_invoice_id(
|
||||
db: AsyncSession, etoplatezhi_payment_id: str
|
||||
) -> EtoplatezhiPayment | None:
|
||||
"""Получает платеж по ID от Etoplatezhi."""
|
||||
result = await db.execute(
|
||||
select(EtoplatezhiPayment).where(EtoplatezhiPayment.etoplatezhi_payment_id == etoplatezhi_payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_id(db: AsyncSession, payment_id: int) -> EtoplatezhiPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(EtoplatezhiPayment).where(EtoplatezhiPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> EtoplatezhiPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(EtoplatezhiPayment)
|
||||
.where(EtoplatezhiPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_etoplatezhi_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: EtoplatezhiPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
etoplatezhi_payment_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> EtoplatezhiPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if etoplatezhi_payment_id is not None:
|
||||
payment.etoplatezhi_payment_id = etoplatezhi_payment_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлен статус платежа Etoplatezhi',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_etoplatezhi_payments(db: AsyncSession, user_id: int) -> list[EtoplatezhiPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(EtoplatezhiPayment).where(
|
||||
EtoplatezhiPayment.user_id == user_id,
|
||||
EtoplatezhiPayment.status == 'pending',
|
||||
EtoplatezhiPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_etoplatezhi_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[EtoplatezhiPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(EtoplatezhiPayment).where(
|
||||
EtoplatezhiPayment.status == 'pending',
|
||||
EtoplatezhiPayment.is_paid == False,
|
||||
EtoplatezhiPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_etoplatezhi_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: EtoplatezhiPayment,
|
||||
transaction_id: int,
|
||||
) -> EtoplatezhiPayment:
|
||||
"""Связывает платеж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -0,0 +1,157 @@
|
||||
"""CRUD операции для платежей Jupiter (FPGate P2P v2.1)."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import JupiterPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_jupiter_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
jupiter_transaction_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> JupiterPayment:
|
||||
"""Создаёт запись о платеже Jupiter."""
|
||||
payment = JupiterPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
jupiter_transaction_id=jupiter_transaction_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж Jupiter', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_order_id(db: AsyncSession, order_id: str) -> JupiterPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(JupiterPayment).where(JupiterPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_invoice_id(db: AsyncSession, jupiter_transaction_id: str) -> JupiterPayment | None:
|
||||
"""Получает платёж по transaction_id, выданному Jupiter."""
|
||||
result = await db.execute(
|
||||
select(JupiterPayment).where(JupiterPayment.jupiter_transaction_id == jupiter_transaction_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_id(db: AsyncSession, payment_id: int) -> JupiterPayment | None:
|
||||
"""Получает платеж по локальному ID."""
|
||||
result = await db.execute(select(JupiterPayment).where(JupiterPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> JupiterPayment | None:
|
||||
"""Получает платёж с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(JupiterPayment)
|
||||
.where(JupiterPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_jupiter_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: JupiterPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
jupiter_transaction_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> JupiterPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if jupiter_transaction_id is not None:
|
||||
payment.jupiter_transaction_id = jupiter_transaction_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлён статус платежа Jupiter',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_jupiter_payments(db: AsyncSession, user_id: int) -> list[JupiterPayment]:
|
||||
"""Возвращает незавершённые платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(JupiterPayment).where(
|
||||
JupiterPayment.user_id == user_id,
|
||||
JupiterPayment.status == 'pending',
|
||||
JupiterPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_jupiter_payments(db: AsyncSession) -> list[JupiterPayment]:
|
||||
"""Возвращает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(JupiterPayment).where(
|
||||
JupiterPayment.status == 'pending',
|
||||
JupiterPayment.is_paid == False,
|
||||
JupiterPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_jupiter_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: JupiterPayment,
|
||||
transaction_id: int,
|
||||
) -> JupiterPayment:
|
||||
"""Связывает платёж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -0,0 +1,155 @@
|
||||
"""CRUD операции для платежей Lava (Lava Business)."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import LavaPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_lava_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
lava_invoice_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> LavaPayment:
|
||||
"""Создаёт запись о платеже Lava."""
|
||||
payment = LavaPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
lava_invoice_id=lava_invoice_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж Lava', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_lava_payment_by_order_id(db: AsyncSession, order_id: str) -> LavaPayment | None:
|
||||
"""Получает платёж по нашему orderId."""
|
||||
result = await db.execute(select(LavaPayment).where(LavaPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_lava_payment_by_invoice_id(db: AsyncSession, lava_invoice_id: str) -> LavaPayment | None:
|
||||
"""Получает платёж по invoice_id, выданному Lava."""
|
||||
result = await db.execute(select(LavaPayment).where(LavaPayment.lava_invoice_id == lava_invoice_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_lava_payment_by_id(db: AsyncSession, payment_id: int) -> LavaPayment | None:
|
||||
"""Получает платёж по локальному ID."""
|
||||
result = await db.execute(select(LavaPayment).where(LavaPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_lava_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> LavaPayment | None:
|
||||
"""Получает платёж с FOR UPDATE-блокировкой."""
|
||||
result = await db.execute(
|
||||
select(LavaPayment)
|
||||
.where(LavaPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_lava_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: LavaPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
lava_invoice_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> LavaPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if lava_invoice_id is not None:
|
||||
payment.lava_invoice_id = lava_invoice_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлён статус платежа Lava',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_lava_payments(db: AsyncSession, user_id: int) -> list[LavaPayment]:
|
||||
"""Возвращает незавершённые платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(LavaPayment).where(
|
||||
LavaPayment.user_id == user_id,
|
||||
LavaPayment.status == 'pending',
|
||||
LavaPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_lava_payments(db: AsyncSession) -> list[LavaPayment]:
|
||||
"""Возвращает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(LavaPayment).where(
|
||||
LavaPayment.status == 'pending',
|
||||
LavaPayment.is_paid == False,
|
||||
LavaPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_lava_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: LavaPayment,
|
||||
transaction_id: int,
|
||||
) -> LavaPayment:
|
||||
"""Связывает платёж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -1,3 +1,4 @@
|
||||
import math
|
||||
import secrets
|
||||
from collections.abc import Iterable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -1296,7 +1297,7 @@ async def add_subscription_servers(
|
||||
|
||||
if paid_prices is None:
|
||||
now = datetime.now(UTC)
|
||||
days_remaining = max(1, (subscription.end_date - now).days)
|
||||
days_remaining = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
|
||||
paid_prices = []
|
||||
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
@@ -27,6 +27,12 @@ REAL_PAYMENT_METHODS = [
|
||||
PaymentMethod.KASSA_AI.value,
|
||||
PaymentMethod.RIOPAY.value,
|
||||
PaymentMethod.SEVERPAY.value,
|
||||
PaymentMethod.ROLLYPAY.value,
|
||||
PaymentMethod.PAYPEAR.value,
|
||||
PaymentMethod.OVERPAY.value,
|
||||
PaymentMethod.AURAPAY.value,
|
||||
PaymentMethod.ETOPLATEZHI.value,
|
||||
PaymentMethod.ANTILOPAY.value,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -162,10 +162,16 @@ class PaymentMethod(Enum):
|
||||
KASSA_AI = 'kassa_ai'
|
||||
RIOPAY = 'riopay'
|
||||
SEVERPAY = 'severpay'
|
||||
APPLE_IAP = 'apple_iap'
|
||||
PAYPEAR = 'paypear'
|
||||
ROLLYPAY = 'rollypay'
|
||||
OVERPAY = 'overpay'
|
||||
AURAPAY = 'aurapay'
|
||||
ETOPLATEZHI = 'etoplatezhi'
|
||||
ANTILOPAY = 'antilopay'
|
||||
JUPITER = 'jupiter'
|
||||
DONUT = 'donut'
|
||||
LAVA = 'lava'
|
||||
MANUAL = 'manual'
|
||||
BALANCE = 'balance'
|
||||
|
||||
@@ -329,6 +335,41 @@ class CryptoBotPayment(Base):
|
||||
return f'<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>'
|
||||
|
||||
|
||||
class AppleTransaction(Base):
|
||||
__tablename__ = 'apple_transactions'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||
|
||||
transaction_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
original_transaction_id = Column(String(64), nullable=True, index=True)
|
||||
product_id = Column(String(128), nullable=False)
|
||||
bundle_id = Column(String(255), nullable=False)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
environment = Column(String(16), nullable=False)
|
||||
|
||||
status = Column(String(50), default='verified')
|
||||
is_paid = Column(Boolean, default=True)
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
refunded_at = Column(AwareDateTime(), nullable=True)
|
||||
|
||||
transaction_id_fk = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship('User', backref='apple_transactions')
|
||||
transaction = relationship('Transaction', backref='apple_transaction')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AppleTransaction(id={self.id}, txn={self.transaction_id}, product={self.product_id}, status={self.status})>'
|
||||
|
||||
|
||||
class HeleketPayment(Base):
|
||||
__tablename__ = 'heleket_payments'
|
||||
|
||||
@@ -1130,6 +1171,318 @@ class AuraPayPayment(Base):
|
||||
return f'<AuraPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class EtoplatezhiPayment(Base):
|
||||
"""Платежи через Etoplatezhi (paymentpage.etoplatezhi.ru)."""
|
||||
|
||||
__tablename__ = 'etoplatezhi_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
etoplatezhi_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Etoplatezhi
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_method = Column(String(32), nullable=True)
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='etoplatezhi_payments')
|
||||
transaction = relationship('Transaction', backref='etoplatezhi_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'canceled', 'amount_mismatch']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<EtoplatezhiPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class AntilopayPayment(Base):
|
||||
"""Платежи через Antilopay (lk.antilopay.com)."""
|
||||
|
||||
__tablename__ = 'antilopay_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
antilopay_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Antilopay (APAY...)
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_method = Column(String(32), nullable=True)
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='antilopay_payments')
|
||||
transaction = relationship('Transaction', backref='antilopay_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'canceled', 'amount_mismatch']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<AntilopayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class JupiterPayment(Base):
|
||||
"""Платежи через Jupiter (FPGate P2P v2.1, app.juppiter.tech)."""
|
||||
|
||||
__tablename__ = 'jupiter_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
jupiter_transaction_id = Column(String(128), unique=True, nullable=True, index=True) # transaction_id от Jupiter
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True) # qrcode_url из details (если есть)
|
||||
payment_method = Column(String(32), nullable=True) # 'sbp' и т.д.
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='jupiter_payments')
|
||||
transaction = relationship('Transaction', backref='jupiter_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'cancelled', 'amount_mismatch', 'declined', 'error']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<JupiterPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class DonutPayment(Base):
|
||||
"""Платежи через Donut P2P (gw.donut.business)."""
|
||||
|
||||
__tablename__ = 'donut_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
donut_transaction_id = Column(String(128), unique=True, nullable=True, index=True) # transaction_id от Donut
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True) # redirect_url или qrcode_url
|
||||
payment_method = Column(String(32), nullable=True) # 'card', 'sbp', 'sbp_qr'
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='donut_payments')
|
||||
transaction = relationship('Transaction', backref='donut_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status in ('pending', 'created', 'processing')
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'cancelled', 'amount_mismatch', 'error']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<DonutPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class LavaPayment(Base):
|
||||
"""Платежи через Lava Business (gate.lava.ru)."""
|
||||
|
||||
__tablename__ = 'lava_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш orderId
|
||||
lava_invoice_id = Column(String(128), unique=True, nullable=True, index=True) # invoice_id (UUID) от Lava
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_method = Column(String(32), nullable=True) # 'card', 'sbp' и т.д.
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='lava_payments')
|
||||
transaction = relationship('Transaction', backref='lava_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status in ('pending', 'created', 'processing')
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'cancel', 'cancelled', 'amount_mismatch', 'error']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
f'<LavaPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
)
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = 'promo_groups'
|
||||
|
||||
@@ -1646,6 +1999,7 @@ class Subscription(Base):
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
last_webhook_update_at = Column(AwareDateTime(), nullable=True)
|
||||
last_revoke_at = Column(AwareDateTime(), nullable=True)
|
||||
|
||||
remnawave_short_uuid = Column(String(255), nullable=True)
|
||||
remnawave_uuid = Column(String(255), nullable=True)
|
||||
|
||||
Vendored
+411
@@ -0,0 +1,411 @@
|
||||
"""Apple App Store Server API client for In-App Purchase verification and webhook handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt as pyjwt
|
||||
import structlog
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, utils as asym_utils
|
||||
from cryptography.hazmat.primitives.hashes import SHA256
|
||||
from cryptography.x509 import load_der_x509_certificate
|
||||
from cryptography.x509.oid import ExtensionOID, ObjectIdentifier
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Apple Root CA - G3 SHA-256 fingerprint for chain pinning
|
||||
# https://www.apple.com/certificateauthority/
|
||||
APPLE_ROOT_CA_G3_SHA256 = bytes.fromhex('63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179')
|
||||
|
||||
# Apple WWDR Intermediate Certificate OID
|
||||
APPLE_WWDR_INTERMEDIATE_OID = ObjectIdentifier('1.2.840.113635.100.6.2.1')
|
||||
|
||||
PRODUCTION_BASE_URL = 'https://api.storekit.itunes.apple.com'
|
||||
SANDBOX_BASE_URL = 'https://api.storekit-sandbox.itunes.apple.com'
|
||||
|
||||
|
||||
class AppleIAPService:
|
||||
"""Service for verifying Apple In-App Purchase transactions and handling notifications."""
|
||||
|
||||
def _get_base_url(self, environment: str | None = None) -> str:
|
||||
env = environment or settings.APPLE_IAP_ENVIRONMENT
|
||||
if env == 'Sandbox':
|
||||
return SANDBOX_BASE_URL
|
||||
return PRODUCTION_BASE_URL
|
||||
|
||||
def _generate_jwt(self) -> str:
|
||||
"""Generate a fresh ES256 JWT for App Store Server API authentication.
|
||||
|
||||
Apple recommends generating a new JWT for each request.
|
||||
"""
|
||||
private_key = settings.get_apple_iap_private_key()
|
||||
if not private_key:
|
||||
raise ValueError('Apple IAP private key is not configured')
|
||||
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
'iss': settings.APPLE_IAP_ISSUER_ID,
|
||||
'iat': now,
|
||||
'exp': now + 3600,
|
||||
'aud': 'appstoreconnect-v1',
|
||||
'bid': settings.APPLE_IAP_BUNDLE_ID,
|
||||
}
|
||||
headers = {
|
||||
'alg': 'ES256',
|
||||
'kid': settings.APPLE_IAP_KEY_ID,
|
||||
'typ': 'JWT',
|
||||
}
|
||||
|
||||
return pyjwt.encode(payload, private_key, algorithm='ES256', headers=headers)
|
||||
|
||||
async def _fetch_transaction(
|
||||
self,
|
||||
transaction_id: str,
|
||||
base_url: str,
|
||||
) -> httpx.Response | None:
|
||||
"""Send a GET request to Apple's transaction lookup endpoint."""
|
||||
url = f'{base_url}/inApps/v1/transactions/{transaction_id}'
|
||||
token = self._generate_jwt()
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
try:
|
||||
return await client.get(
|
||||
url,
|
||||
headers={'Authorization': f'Bearer {token}'},
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error('Apple API request failed', error=str(e), transaction_id=transaction_id)
|
||||
return None
|
||||
|
||||
async def verify_transaction(
|
||||
self,
|
||||
transaction_id: str,
|
||||
environment: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Verify a transaction with Apple's App Store Server API.
|
||||
|
||||
Follows Apple's recommendation: if the configured environment returns
|
||||
a 4xx error, retries against the opposite environment. This ensures
|
||||
Sandbox purchases made during App Review still verify when the server
|
||||
is configured for Production.
|
||||
"""
|
||||
primary_url = self._get_base_url(environment)
|
||||
# Determine fallback URL (opposite environment)
|
||||
fallback_url = SANDBOX_BASE_URL if primary_url == PRODUCTION_BASE_URL else PRODUCTION_BASE_URL
|
||||
|
||||
for attempt_url in (primary_url, fallback_url):
|
||||
response = await self._fetch_transaction(transaction_id, attempt_url)
|
||||
if response is None:
|
||||
return None # network error -- don't retry
|
||||
|
||||
if response.status_code == 200:
|
||||
return self._parse_transaction_response(response, transaction_id)
|
||||
|
||||
# 4xx on primary -> retry on fallback per Apple docs
|
||||
if 400 <= response.status_code < 500 and attempt_url == primary_url:
|
||||
logger.info(
|
||||
'Apple API returned 4xx on primary env, retrying fallback',
|
||||
status=response.status_code,
|
||||
primary=attempt_url,
|
||||
fallback=fallback_url,
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
continue
|
||||
|
||||
# Log the final failure
|
||||
self._log_api_error(response, transaction_id)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _parse_transaction_response(
|
||||
self,
|
||||
response: httpx.Response,
|
||||
transaction_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Extract and verify signedTransactionInfo from a 200 response."""
|
||||
data = response.json()
|
||||
signed_transaction_info = data.get('signedTransactionInfo')
|
||||
if signed_transaction_info:
|
||||
decoded = self._verify_and_decode_jws(signed_transaction_info)
|
||||
if decoded:
|
||||
return decoded
|
||||
logger.warning('Failed to verify signedTransactionInfo', transaction_id=transaction_id)
|
||||
return None
|
||||
logger.warning('No signedTransactionInfo in response', transaction_id=transaction_id)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _log_api_error(response: httpx.Response, transaction_id: str) -> None:
|
||||
if response.status_code == 404:
|
||||
logger.warning('Apple transaction not found', transaction_id=transaction_id)
|
||||
elif response.status_code == 401:
|
||||
logger.error('Apple API auth failed -- check key configuration')
|
||||
elif response.status_code == 429:
|
||||
logger.warning('Apple API rate limit exceeded')
|
||||
else:
|
||||
logger.error(
|
||||
'Apple API unexpected status',
|
||||
status=response.status_code,
|
||||
body=response.text[:500],
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
|
||||
def validate_transaction_info(self, txn_info: dict[str, Any], expected_product_id: str) -> str | None:
|
||||
"""Validate decoded transaction info fields.
|
||||
|
||||
Returns None if valid, or an error message string.
|
||||
"""
|
||||
bundle_id = txn_info.get('bundleId')
|
||||
if bundle_id != settings.APPLE_IAP_BUNDLE_ID:
|
||||
return f'Bundle ID mismatch: {bundle_id}'
|
||||
|
||||
product_id = txn_info.get('productId')
|
||||
if product_id != expected_product_id:
|
||||
return f'Product ID mismatch: {product_id} != {expected_product_id}'
|
||||
|
||||
txn_type = txn_info.get('type')
|
||||
if txn_type != 'Consumable':
|
||||
return f'Unexpected transaction type: {txn_type}'
|
||||
|
||||
if txn_info.get('revocationDate'):
|
||||
return f'Transaction was revoked at {txn_info["revocationDate"]}'
|
||||
|
||||
return None
|
||||
|
||||
def _verify_and_decode_jws(self, jws_token: str) -> dict[str, Any] | None:
|
||||
"""Verify x5c certificate chain and ES256 signature, then decode the JWS payload.
|
||||
|
||||
Returns the decoded payload dict, or None if verification fails.
|
||||
Used for both outer notification payloads and inner signed data
|
||||
(signedTransactionInfo, signedRenewalInfo).
|
||||
"""
|
||||
try:
|
||||
parts = jws_token.split('.')
|
||||
if len(parts) != 3:
|
||||
logger.warning('Invalid JWS format: expected 3 parts')
|
||||
return None
|
||||
|
||||
# Decode header to get x5c chain
|
||||
header_b64 = parts[0]
|
||||
padding = 4 - len(header_b64) % 4
|
||||
if padding != 4:
|
||||
header_b64 += '=' * padding
|
||||
header_json = base64.urlsafe_b64decode(header_b64)
|
||||
header = json.loads(header_json)
|
||||
|
||||
x5c_chain = header.get('x5c', [])
|
||||
if not x5c_chain:
|
||||
logger.warning('No x5c certificate chain in JWS header')
|
||||
return None
|
||||
|
||||
# Verify the certificate chain
|
||||
if not self._verify_x5c_chain(x5c_chain):
|
||||
logger.warning('x5c certificate chain verification failed')
|
||||
return None
|
||||
|
||||
# Verify the signature using the leaf certificate
|
||||
leaf_cert_der = base64.b64decode(x5c_chain[0])
|
||||
leaf_cert = load_der_x509_certificate(leaf_cert_der)
|
||||
public_key = leaf_cert.public_key()
|
||||
|
||||
signing_input = f'{parts[0]}.{parts[1]}'.encode('ascii')
|
||||
signature_b64 = parts[2]
|
||||
sig_padding = 4 - len(signature_b64) % 4
|
||||
if sig_padding != 4:
|
||||
signature_b64 += '=' * sig_padding
|
||||
signature = base64.urlsafe_b64decode(signature_b64)
|
||||
|
||||
# ES256 signatures from JWS are in raw (r||s) format, convert to DER
|
||||
if len(signature) == 64:
|
||||
r = int.from_bytes(signature[:32], 'big')
|
||||
s = int.from_bytes(signature[32:], 'big')
|
||||
signature = asym_utils.encode_dss_signature(r, s)
|
||||
|
||||
public_key.verify(signature, signing_input, ec.ECDSA(SHA256()))
|
||||
|
||||
# Signature valid -- decode payload
|
||||
return self._decode_jws_payload(jws_token)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('JWS verification failed', error=str(e), exc_info=True)
|
||||
return None
|
||||
|
||||
def verify_notification(self, signed_payload: str) -> dict[str, Any] | None:
|
||||
"""Verify and decode an App Store Server Notification V2 payload.
|
||||
|
||||
Verifies the JWS x5c certificate chain, then returns the decoded payload.
|
||||
Returns None if verification fails.
|
||||
"""
|
||||
return self._verify_and_decode_jws(signed_payload)
|
||||
|
||||
def _verify_x5c_chain(self, x5c_chain: list[str]) -> bool:
|
||||
"""Verify the x5c certificate chain ends with an Apple Root CA."""
|
||||
try:
|
||||
if len(x5c_chain) < 2:
|
||||
logger.warning('x5c chain too short', length=len(x5c_chain))
|
||||
return False
|
||||
|
||||
certs = []
|
||||
for cert_b64 in x5c_chain:
|
||||
cert_der = base64.b64decode(cert_b64)
|
||||
cert = load_der_x509_certificate(cert_der)
|
||||
certs.append(cert)
|
||||
|
||||
# Check certificate validity periods
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
for i, cert in enumerate(certs):
|
||||
if now < cert.not_valid_before_utc:
|
||||
logger.warning('x5c cert not yet valid', index=i, not_before=str(cert.not_valid_before_utc))
|
||||
return False
|
||||
if now > cert.not_valid_after_utc:
|
||||
logger.warning('x5c cert expired', index=i, not_after=str(cert.not_valid_after_utc))
|
||||
return False
|
||||
|
||||
# Pin the root (last) certificate by SHA-256 fingerprint
|
||||
root_cert = certs[-1]
|
||||
root_fingerprint = root_cert.fingerprint(SHA256())
|
||||
if root_fingerprint != APPLE_ROOT_CA_G3_SHA256:
|
||||
logger.warning(
|
||||
'Root CA fingerprint mismatch -- not genuine Apple Root CA - G3',
|
||||
got=root_fingerprint.hex(),
|
||||
)
|
||||
return False
|
||||
|
||||
# Verify each certificate is signed by the next one in the chain
|
||||
for i in range(len(certs) - 1):
|
||||
child = certs[i]
|
||||
parent = certs[i + 1]
|
||||
parent_public_key = parent.public_key()
|
||||
parent_public_key.verify(
|
||||
child.signature,
|
||||
child.tbs_certificate_bytes,
|
||||
ec.ECDSA(child.signature_hash_algorithm),
|
||||
)
|
||||
|
||||
# FIX 3: Validate Apple WWDR intermediate OID
|
||||
# The intermediate cert (index 1) must contain the Apple WWDR OID
|
||||
# to ensure it is a genuine Apple WWDR intermediate certificate.
|
||||
if len(certs) >= 2:
|
||||
intermediate_cert = certs[1]
|
||||
try:
|
||||
# Check for the Apple WWDR OID in certificate extensions
|
||||
found_apple_oid = False
|
||||
for ext in intermediate_cert.extensions:
|
||||
if ext.oid == ExtensionOID.CERTIFICATE_POLICIES:
|
||||
for policy in ext.value:
|
||||
if policy.policy_identifier == APPLE_WWDR_INTERMEDIATE_OID:
|
||||
found_apple_oid = True
|
||||
break
|
||||
if found_apple_oid:
|
||||
break
|
||||
if not found_apple_oid:
|
||||
logger.warning(
|
||||
'Intermediate cert missing Apple WWDR OID',
|
||||
oid=str(APPLE_WWDR_INTERMEDIATE_OID),
|
||||
)
|
||||
return False
|
||||
except x509.ExtensionNotFound:
|
||||
logger.warning('Intermediate cert has no certificate policies extension')
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error('x5c chain verification error', error=str(e))
|
||||
return False
|
||||
|
||||
def _decode_jws_payload(self, jws_token: str) -> dict[str, Any] | None:
|
||||
"""Decode the payload from a JWS token without signature verification.
|
||||
|
||||
Use only after the signature has already been verified.
|
||||
"""
|
||||
try:
|
||||
parts = jws_token.split('.')
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
|
||||
payload_b64 = parts[1]
|
||||
# Add base64url padding
|
||||
padding = 4 - len(payload_b64) % 4
|
||||
if padding != 4:
|
||||
payload_b64 += '=' * padding
|
||||
|
||||
payload_json = base64.urlsafe_b64decode(payload_b64)
|
||||
return json.loads(payload_json)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Failed to decode JWS payload', error=str(e))
|
||||
return None
|
||||
|
||||
async def send_consumption_info(
|
||||
self,
|
||||
transaction_id: str,
|
||||
customer_consented: bool,
|
||||
consumption_status: int = 0,
|
||||
delivery_status: int = 0,
|
||||
lifetime_dollars_purchased: int = 0,
|
||||
lifetime_dollars_refunded: int = 0,
|
||||
platform: int = 1,
|
||||
play_time: int = 0,
|
||||
sample_content_provided: bool = False,
|
||||
user_status: int = 0,
|
||||
environment: str | None = None,
|
||||
refund_preference: int | None = None,
|
||||
) -> bool:
|
||||
"""Send consumption information to Apple in response to CONSUMPTION_REQUEST.
|
||||
|
||||
Must be sent within 12 hours of receiving the notification.
|
||||
"""
|
||||
base_url = self._get_base_url(environment)
|
||||
url = f'{base_url}/inApps/v2/transactions/consumption/{transaction_id}'
|
||||
token = self._generate_jwt()
|
||||
|
||||
body: dict[str, Any] = {
|
||||
'customerConsented': customer_consented,
|
||||
'consumptionStatus': consumption_status,
|
||||
'deliveryStatus': delivery_status,
|
||||
'lifetimeDollarsPurchased': lifetime_dollars_purchased,
|
||||
'lifetimeDollarsRefunded': lifetime_dollars_refunded,
|
||||
'platform': platform,
|
||||
'playTime': play_time,
|
||||
'sampleContentProvided': sample_content_provided,
|
||||
'userStatus': user_status,
|
||||
}
|
||||
if refund_preference is not None:
|
||||
body['refundPreference'] = refund_preference
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
try:
|
||||
response = await client.put(
|
||||
url,
|
||||
json=body,
|
||||
headers={
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error('Apple consumption API request failed', error=str(e))
|
||||
return False
|
||||
|
||||
if response.status_code == 202:
|
||||
logger.info('Consumption info sent to Apple', transaction_id=transaction_id)
|
||||
return True
|
||||
|
||||
logger.error(
|
||||
'Apple consumption API error',
|
||||
status=response.status_code,
|
||||
body=response.text[:500],
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
return False
|
||||
Vendored
+21
@@ -541,6 +541,27 @@ class RemnaWaveAPI:
|
||||
return []
|
||||
raise
|
||||
|
||||
async def get_subscription_request_history(
|
||||
self,
|
||||
uuid: str,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
) -> dict:
|
||||
"""Get subscription request history for a panel user.
|
||||
|
||||
Returns dict with 'total' and 'records' list.
|
||||
Each record has: id, userUuid, requestAt, requestIp, userAgent.
|
||||
"""
|
||||
try:
|
||||
response = await self._make_request(
|
||||
'GET',
|
||||
f'/api/users/{uuid}/subscription-request-history',
|
||||
params={'offset': offset, 'limit': limit},
|
||||
)
|
||||
return response.get('response', {'total': 0, 'records': []})
|
||||
except RemnaWaveAPIError:
|
||||
return {'total': 0, 'records': []}
|
||||
|
||||
async def update_user(
|
||||
self,
|
||||
uuid: str,
|
||||
|
||||
Vendored
+339
@@ -57,6 +57,9 @@ class WebhookServer:
|
||||
|
||||
self.app.router.add_get('/health', self._health_check)
|
||||
|
||||
if settings.is_apple_iap_enabled():
|
||||
self.app.router.add_post(settings.APPLE_IAP_WEBHOOK_PATH, self._apple_iap_webhook_handler)
|
||||
|
||||
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_mulenpay_enabled():
|
||||
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
|
||||
@@ -64,6 +67,8 @@ class WebhookServer:
|
||||
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_freekassa_enabled():
|
||||
self.app.router.add_options(settings.FREEKASSA_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_apple_iap_enabled():
|
||||
self.app.router.add_options(settings.APPLE_IAP_WEBHOOK_PATH, self._options_handler)
|
||||
|
||||
logger.info('Webhook сервер настроен:')
|
||||
logger.info('Tribute webhook: POST', TRIBUTE_WEBHOOK_PATH=settings.TRIBUTE_WEBHOOK_PATH)
|
||||
@@ -76,6 +81,8 @@ class WebhookServer:
|
||||
logger.info('CryptoBot webhook: POST', CRYPTOBOT_WEBHOOK_PATH=settings.CRYPTOBOT_WEBHOOK_PATH)
|
||||
if settings.is_freekassa_enabled():
|
||||
logger.info('Freekassa webhook: POST', FREEKASSA_WEBHOOK_PATH=settings.FREEKASSA_WEBHOOK_PATH)
|
||||
if settings.is_apple_iap_enabled():
|
||||
logger.info('Apple IAP webhook: POST', APPLE_IAP_WEBHOOK_PATH=settings.APPLE_IAP_WEBHOOK_PATH)
|
||||
logger.info(' - Health check: GET /health')
|
||||
|
||||
return self.app
|
||||
@@ -491,3 +498,335 @@ class WebhookServer:
|
||||
except Exception as e:
|
||||
logger.error('Критическая ошибка обработки Freekassa webhook', error=e, exc_info=True)
|
||||
return web.Response(text='NO', status=500)
|
||||
|
||||
async def _apple_iap_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
"""Handle Apple App Store Server Notifications V2."""
|
||||
try:
|
||||
logger.info('Получен Apple IAP webhook', method=request.method, path=request.path)
|
||||
|
||||
raw_body = await request.read()
|
||||
if not raw_body:
|
||||
logger.warning('Пустой Apple IAP webhook')
|
||||
return web.Response(status=400)
|
||||
|
||||
try:
|
||||
body = json.loads(raw_body.decode('utf-8'))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
logger.error('Ошибка парсинга Apple IAP webhook', error=e)
|
||||
return web.Response(status=400)
|
||||
|
||||
signed_payload = body.get('signedPayload')
|
||||
if not signed_payload:
|
||||
logger.warning('No signedPayload in Apple webhook')
|
||||
return web.Response(status=400)
|
||||
|
||||
# Verify and decode the notification
|
||||
from app.external.apple_iap import AppleIAPService
|
||||
|
||||
apple_service = AppleIAPService()
|
||||
notification = apple_service.verify_notification(signed_payload)
|
||||
if not notification:
|
||||
logger.warning('Apple webhook signature verification failed')
|
||||
return web.Response(status=403)
|
||||
|
||||
notification_type = notification.get('notificationType', '')
|
||||
subtype = notification.get('subtype', '')
|
||||
|
||||
# Verify notification environment matches our config
|
||||
# FIX 11: removed dead initial assignment of expected_envs
|
||||
notif_env = notification.get('data', {}).get('environment', '')
|
||||
if settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
||||
expected_envs = {'Production', 'Sandbox'} # Sandbox for App Review
|
||||
else:
|
||||
expected_envs = {'Sandbox'}
|
||||
if notif_env and notif_env not in expected_envs:
|
||||
logger.warning(
|
||||
'Apple webhook environment mismatch',
|
||||
expected=settings.APPLE_IAP_ENVIRONMENT,
|
||||
received=notif_env,
|
||||
)
|
||||
return web.Response(status=200) # ACK but ignore
|
||||
|
||||
logger.info(
|
||||
'Apple notification received',
|
||||
notification_type=notification_type,
|
||||
subtype=subtype,
|
||||
environment=notif_env,
|
||||
)
|
||||
|
||||
# Handle notification types
|
||||
if notification_type == 'TEST':
|
||||
logger.info('Apple TEST notification received -- OK')
|
||||
return web.Response(status=200)
|
||||
|
||||
if notification_type == 'REFUND':
|
||||
await self._handle_apple_refund(notification, apple_service)
|
||||
return web.Response(status=200)
|
||||
|
||||
if notification_type == 'REFUND_REVERSED':
|
||||
await self._handle_apple_refund_reversed(notification)
|
||||
return web.Response(status=200)
|
||||
|
||||
if notification_type == 'CONSUMPTION_REQUEST':
|
||||
await self._handle_apple_consumption_request(notification, apple_service)
|
||||
return web.Response(status=200)
|
||||
|
||||
if notification_type in ('ONE_TIME_CHARGE', 'REFUND_DECLINED'):
|
||||
logger.info('Apple notification logged', notification_type=notification_type)
|
||||
return web.Response(status=200)
|
||||
|
||||
logger.info('Unhandled Apple notification type', notification_type=notification_type)
|
||||
return web.Response(status=200)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Критическая ошибка обработки Apple IAP webhook', error=e, exc_info=True)
|
||||
return web.Response(status=500)
|
||||
|
||||
async def _handle_apple_refund(self, notification: dict, apple_service) -> None:
|
||||
"""Handle REFUND notification -- deduct credited balance."""
|
||||
try:
|
||||
data = notification.get('data', {})
|
||||
signed_txn_info = data.get('signedTransactionInfo')
|
||||
if not signed_txn_info:
|
||||
logger.warning('No signedTransactionInfo in REFUND notification')
|
||||
return
|
||||
|
||||
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
|
||||
if not txn_info:
|
||||
logger.warning('Failed to verify REFUND transaction info')
|
||||
return
|
||||
|
||||
apple_txn_id = str(txn_info.get('transactionId') or '')
|
||||
original_txn_id = str(txn_info.get('originalTransactionId') or '')
|
||||
product_id = txn_info.get('productId', '')
|
||||
|
||||
from app.database.crud.apple_iap import (
|
||||
mark_apple_transaction_refunded,
|
||||
)
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
|
||||
lookup_id = original_txn_id or apple_txn_id
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
from app.database.crud.apple_iap import get_apple_transaction_by_transaction_id_for_update
|
||||
|
||||
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, lookup_id)
|
||||
if not apple_txn:
|
||||
# Try the other ID
|
||||
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, apple_txn_id)
|
||||
|
||||
if not apple_txn:
|
||||
logger.warning(
|
||||
'Apple REFUND: transaction not found',
|
||||
transaction_id=apple_txn_id,
|
||||
original_transaction_id=original_txn_id,
|
||||
)
|
||||
return
|
||||
|
||||
if apple_txn.status == 'refunded':
|
||||
logger.info('Apple REFUND: already refunded', transaction_id=lookup_id)
|
||||
return
|
||||
|
||||
if apple_txn.environment == 'Sandbox' and settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
||||
logger.info(
|
||||
'Apple REFUND: ignoring sandbox refund on production',
|
||||
transaction_id=lookup_id,
|
||||
user_id=apple_txn.user_id,
|
||||
)
|
||||
return
|
||||
|
||||
# FIX 6: Lock user row with FOR UPDATE before reading balance
|
||||
# to prevent race condition in min() balance cap calculation
|
||||
user = await lock_user_for_pricing(db, apple_txn.user_id)
|
||||
if not user:
|
||||
logger.error('Apple REFUND: user not found', user_id=apple_txn.user_id)
|
||||
return
|
||||
|
||||
# Cap deduction to current balance to prevent negative balance
|
||||
refund_amount = min(apple_txn.amount_kopeks, user.balance_kopeks)
|
||||
if refund_amount < apple_txn.amount_kopeks:
|
||||
logger.warning(
|
||||
'Apple REFUND: partial balance deduction (user already spent funds)',
|
||||
full_amount=apple_txn.amount_kopeks,
|
||||
deducted=refund_amount,
|
||||
user_balance=user.balance_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
# Disable active subscriptions -- funds were spent and refunded
|
||||
from app.database.crud.subscription import (
|
||||
deactivate_subscription,
|
||||
get_active_subscriptions_by_user_id,
|
||||
)
|
||||
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
||||
for sub in active_subs:
|
||||
await deactivate_subscription(db, sub, commit=False)
|
||||
logger.warning(
|
||||
'Apple REFUND: disabled subscription due to insufficient balance',
|
||||
subscription_id=sub.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
if refund_amount > 0:
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
await subtract_user_balance(
|
||||
db=db,
|
||||
user=user,
|
||||
amount_kopeks=refund_amount,
|
||||
description=f'Возврат Apple IAP: {product_id}',
|
||||
create_transaction=True,
|
||||
payment_method=PaymentMethod.APPLE_IAP,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
await mark_apple_transaction_refunded(db, apple_txn.transaction_id)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Apple REFUND processed',
|
||||
transaction_id=apple_txn.transaction_id,
|
||||
amount_kopeks=apple_txn.amount_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error handling Apple REFUND', error=e, exc_info=True)
|
||||
|
||||
async def _handle_apple_refund_reversed(self, notification: dict) -> None:
|
||||
"""Handle REFUND_REVERSED -- re-credit balance that was previously deducted."""
|
||||
try:
|
||||
data = notification.get('data', {})
|
||||
signed_txn_info = data.get('signedTransactionInfo')
|
||||
if not signed_txn_info:
|
||||
logger.warning('No signedTransactionInfo in REFUND_REVERSED notification')
|
||||
return
|
||||
|
||||
from app.external.apple_iap import AppleIAPService
|
||||
|
||||
apple_service = AppleIAPService()
|
||||
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
|
||||
if not txn_info:
|
||||
logger.warning('Failed to verify REFUND_REVERSED transaction info')
|
||||
return
|
||||
|
||||
apple_txn_id = str(txn_info.get('transactionId') or '')
|
||||
original_txn_id = str(txn_info.get('originalTransactionId') or '')
|
||||
product_id = txn_info.get('productId', '')
|
||||
|
||||
from app.database.crud.apple_iap import (
|
||||
get_apple_transaction_by_transaction_id_for_update,
|
||||
)
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import PaymentMethod
|
||||
|
||||
lookup_id = original_txn_id or apple_txn_id
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# FIX 7: Use FOR UPDATE lock on apple_transactions row
|
||||
# before checking status to prevent idempotency race
|
||||
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, lookup_id)
|
||||
if not apple_txn:
|
||||
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, apple_txn_id)
|
||||
|
||||
if not apple_txn:
|
||||
logger.warning(
|
||||
'Apple REFUND_REVERSED: transaction not found',
|
||||
transaction_id=apple_txn_id,
|
||||
)
|
||||
return
|
||||
|
||||
if apple_txn.status != 'refunded':
|
||||
logger.info(
|
||||
'Apple REFUND_REVERSED: transaction not in refunded state',
|
||||
transaction_id=lookup_id,
|
||||
status=apple_txn.status,
|
||||
)
|
||||
return
|
||||
|
||||
if apple_txn.environment == 'Sandbox' and settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
||||
logger.info(
|
||||
'Apple REFUND_REVERSED: ignoring sandbox on production',
|
||||
transaction_id=lookup_id,
|
||||
)
|
||||
return
|
||||
|
||||
user = await get_user_by_id(db, apple_txn.user_id)
|
||||
if not user:
|
||||
logger.error('Apple REFUND_REVERSED: user not found', user_id=apple_txn.user_id)
|
||||
return
|
||||
|
||||
# Re-credit the balance
|
||||
await add_user_balance(
|
||||
db=db,
|
||||
user=user,
|
||||
amount_kopeks=apple_txn.amount_kopeks,
|
||||
description=f'Отмена возврата Apple IAP: {product_id}',
|
||||
payment_method=PaymentMethod.APPLE_IAP,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
apple_txn.status = 'verified'
|
||||
apple_txn.refunded_at = None
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Apple REFUND_REVERSED processed -- balance re-credited',
|
||||
transaction_id=lookup_id,
|
||||
amount_kopeks=apple_txn.amount_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error handling Apple REFUND_REVERSED', error=e, exc_info=True)
|
||||
|
||||
async def _handle_apple_consumption_request(self, notification: dict, apple_service) -> None:
|
||||
"""Handle CONSUMPTION_REQUEST -- send consumption info to Apple."""
|
||||
try:
|
||||
data = notification.get('data', {})
|
||||
signed_txn_info = data.get('signedTransactionInfo')
|
||||
if not signed_txn_info:
|
||||
logger.warning('No signedTransactionInfo in CONSUMPTION_REQUEST')
|
||||
return
|
||||
|
||||
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
|
||||
if not txn_info:
|
||||
logger.warning('Failed to verify CONSUMPTION_REQUEST transaction info')
|
||||
return
|
||||
|
||||
apple_txn_id = str(txn_info.get('transactionId') or '')
|
||||
environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT)
|
||||
|
||||
from app.database.crud.apple_iap import get_apple_transaction_by_transaction_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
apple_txn = await get_apple_transaction_by_transaction_id(db, apple_txn_id)
|
||||
|
||||
# Determine if balance was consumed (spent on subscriptions)
|
||||
# consumptionStatus: 0 = undeclared, 1 = not consumed, 2 = partially consumed, 3 = fully consumed
|
||||
consumption_status = 0
|
||||
if apple_txn and apple_txn.status == 'verified':
|
||||
consumption_status = 3 # Balance was credited and likely spent
|
||||
|
||||
# customerConsented must be false -- we cannot prompt the user
|
||||
# in a server-to-server webhook. Apple accepts the response
|
||||
# regardless, but the consumption data weight may be lower.
|
||||
await apple_service.send_consumption_info(
|
||||
transaction_id=apple_txn_id,
|
||||
customer_consented=False,
|
||||
consumption_status=consumption_status,
|
||||
delivery_status=0, # 0 = delivered
|
||||
platform=1, # 1 = Apple
|
||||
environment=environment,
|
||||
)
|
||||
|
||||
logger.info('Apple CONSUMPTION_REQUEST handled', transaction_id=apple_txn_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error handling Apple CONSUMPTION_REQUEST', error=e, exc_info=True)
|
||||
|
||||
@@ -313,7 +313,7 @@ async def restore_backup_start(callback: types.CallbackQuery, db_user: User, db:
|
||||
else:
|
||||
text = """📥 <b>Восстановление из бекапа</b>
|
||||
|
||||
📎 Отправьте файл бекапа (.json или .json.gz)
|
||||
📎 Отправьте файл бекапа (.json, .json.gz или .tar.gz)
|
||||
|
||||
⚠️ <b>ВАЖНО:</b>
|
||||
• Файл должен быть создан этой системой бекапов
|
||||
@@ -383,7 +383,7 @@ async def restore_backup_execute(callback: types.CallbackQuery, db_user: User, d
|
||||
async def handle_backup_file_upload(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
if not message.document:
|
||||
await message.answer(
|
||||
'❌ Пожалуйста, отправьте файл бекапа (.json или .json.gz)',
|
||||
'❌ Пожалуйста, отправьте файл бекапа (.json, .json.gz или .tar.gz)',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text='◀️ Отмена', callback_data='backup_panel')]]
|
||||
),
|
||||
@@ -391,10 +391,11 @@ async def handle_backup_file_upload(message: types.Message, db_user: User, db: A
|
||||
return
|
||||
|
||||
document = message.document
|
||||
allowed_extensions = ('.json', '.json.gz', '.tar.gz', '.tar')
|
||||
|
||||
if not (document.file_name.endswith('.json') or document.file_name.endswith('.json.gz')):
|
||||
if not document.file_name or not any(document.file_name.endswith(ext) for ext in allowed_extensions):
|
||||
await message.answer(
|
||||
'❌ Неподдерживаемый формат файла. Загрузите .json или .json.gz файл',
|
||||
'❌ Неподдерживаемый формат файла. Загрузите .json, .json.gz или .tar.gz файл',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text='◀️ Отмена', callback_data='backup_panel')]]
|
||||
),
|
||||
|
||||
@@ -158,7 +158,6 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
'LOG',
|
||||
'MODERATION',
|
||||
'DEBUG',
|
||||
'EXTERNAL_ADMIN',
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -175,6 +175,7 @@ async def _build_notification_preview_message(language: str, notification_type:
|
||||
message = template.format(
|
||||
end_date=(now - timedelta(days=1)).strftime('%d.%m.%Y %H:%M'),
|
||||
price=price_30_days,
|
||||
tariff_label='',
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -213,6 +214,7 @@ async def _build_notification_preview_message(language: str, notification_type:
|
||||
percent=percent,
|
||||
expires_at=(now + timedelta(hours=valid_hours)).strftime('%d.%m.%Y %H:%M'),
|
||||
trigger_days=3,
|
||||
tariff_label='',
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -258,6 +260,7 @@ async def _build_notification_preview_message(language: str, notification_type:
|
||||
percent=percent,
|
||||
trigger_days=trigger_days,
|
||||
expires_at=(now + timedelta(hours=valid_hours)).strftime('%d.%m.%Y %H:%M'),
|
||||
tariff_label='',
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Handler for Antilopay balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_antilopay_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method_type: str | None = None,
|
||||
):
|
||||
"""
|
||||
Common logic for creating Antilopay payment and sending response.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_antilopay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_antilopay_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'\U0001f4b3 Оплатить {amount}\u20bd',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'ANTILOPAY_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}\u20bd</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info('Antilopay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_antilopay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.ANTILOPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.ANTILOPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}\u20bd',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}\u20bd',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'antilopay')
|
||||
# antilopay_sbp → 'sbp', antilopay_card → 'card', antilopay_sberpay → 'sberpay', antilopay → None
|
||||
payment_method_type = _extract_service_type(payment_method)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_antilopay_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
|
||||
ANTILOPAY_PAYMENT_METHODS = {'antilopay', 'antilopay_sbp', 'antilopay_card', 'antilopay_sberpay'}
|
||||
|
||||
ANTILOPAY_SERVICE_MAP: dict[str, str | None] = {
|
||||
'antilopay': None,
|
||||
'antilopay_sbp': 'sbp',
|
||||
'antilopay_card': 'card',
|
||||
'antilopay_sberpay': 'sberpay',
|
||||
}
|
||||
|
||||
|
||||
def _extract_service_type(payment_method: str) -> str | None:
|
||||
return ANTILOPAY_SERVICE_MAP.get(payment_method)
|
||||
|
||||
|
||||
async def _start_antilopay_topup_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""Common logic for starting Antilopay top-up (generic / SBP / card / SberPay)."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method=payment_method)
|
||||
|
||||
min_amount = settings.ANTILOPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.ANTILOPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
|
||||
# Choose display name based on sub-method
|
||||
if payment_method == 'antilopay_sbp':
|
||||
display_name = settings.get_antilopay_sbp_display_name()
|
||||
elif payment_method == 'antilopay_card':
|
||||
display_name = settings.get_antilopay_card_display_name()
|
||||
elif payment_method == 'antilopay_sberpay':
|
||||
display_name = settings.get_antilopay_sberpay_display_name()
|
||||
else:
|
||||
display_name = settings.get_antilopay_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'ANTILOPAY_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}\u20bd\n'
|
||||
'Максимум: {max_amount}\u20bd',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_antilopay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_antilopay_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_antilopay_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay_card')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_antilopay_sberpay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay_sberpay')
|
||||
@@ -39,6 +39,7 @@ async def _create_aurapay_payment_and_respond(
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method_type: str | None = None,
|
||||
):
|
||||
"""
|
||||
Common logic for creating AuraPay payment and sending response.
|
||||
@@ -61,6 +62,7 @@ async def _create_aurapay_payment_and_respond(
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
if not result:
|
||||
@@ -179,6 +181,11 @@ async def process_aurapay_payment_amount(
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'aurapay')
|
||||
# aurapay_sbp → 'sbp', aurapay_card → 'card', aurapay → None
|
||||
payment_method_type = _extract_service_type(payment_method)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_aurapay_payment_and_respond(
|
||||
@@ -187,19 +194,30 @@ async def process_aurapay_payment_amount(
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_aurapay_topup(
|
||||
AURAPAY_PAYMENT_METHODS = {'aurapay', 'aurapay_sbp', 'aurapay_card'}
|
||||
|
||||
AURAPAY_SERVICE_MAP: dict[str, str | None] = {
|
||||
'aurapay': None,
|
||||
'aurapay_sbp': 'sbp',
|
||||
'aurapay_card': 'card',
|
||||
}
|
||||
|
||||
|
||||
def _extract_service_type(payment_method: str) -> str | None:
|
||||
return AURAPAY_SERVICE_MAP.get(payment_method)
|
||||
|
||||
|
||||
async def _start_aurapay_topup_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""
|
||||
Start AuraPay top-up process - ask for amount.
|
||||
"""
|
||||
"""Common logic for starting AuraPay top-up (generic / SBP / card)."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
@@ -213,11 +231,18 @@ async def start_aurapay_topup(
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='aurapay')
|
||||
await state.update_data(payment_method=payment_method)
|
||||
|
||||
min_amount = settings.AURAPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.AURAPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_aurapay_display_name()
|
||||
|
||||
# Choose display name based on sub-method
|
||||
if payment_method == 'aurapay_sbp':
|
||||
display_name = settings.get_aurapay_sbp_display_name()
|
||||
elif payment_method == 'aurapay_card':
|
||||
display_name = settings.get_aurapay_card_display_name()
|
||||
else:
|
||||
display_name = settings.get_aurapay_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -245,3 +270,33 @@ async def start_aurapay_topup(
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_aurapay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_aurapay_topup_impl(callback, db_user, state, 'aurapay')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_aurapay_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_aurapay_topup_impl(callback, db_user, state, 'aurapay_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_aurapay_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_aurapay_topup_impl(callback, db_user, state, 'aurapay_card')
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Handler for Donut balance top-up (Donut P2P)."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
DONUT_PAYMENT_METHODS = {'donut', 'donut_card', 'donut_sbp', 'donut_sbp_qr'}
|
||||
|
||||
DONUT_SERVICE_MAP: dict[str, str | None] = {
|
||||
'donut': None,
|
||||
'donut_card': 'card',
|
||||
'donut_sbp': 'sbp',
|
||||
'donut_sbp_qr': 'sbp_qr',
|
||||
}
|
||||
|
||||
|
||||
def _extract_service_type(payment_method: str) -> str | None:
|
||||
return DONUT_SERVICE_MAP.get(payment_method)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def _get_display_name(payment_method: str) -> str:
|
||||
if payment_method == 'donut_card':
|
||||
return settings.get_donut_card_display_name()
|
||||
if payment_method == 'donut_sbp':
|
||||
return settings.get_donut_sbp_display_name()
|
||||
if payment_method == 'donut_sbp_qr':
|
||||
return settings.get_donut_sbp_qr_display_name()
|
||||
return settings.get_donut_display_name()
|
||||
|
||||
|
||||
async def _create_donut_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method_type: str | None = None,
|
||||
display_name: str | None = None,
|
||||
):
|
||||
"""Создаёт платёж Donut и отправляет ссылку пользователю."""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
payment_service = PaymentService()
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_donut_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(error_text, parse_mode='HTML')
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
name = display_name or settings.get_donut_display_name()
|
||||
|
||||
pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}₽').format(
|
||||
amount=f'{amount_rub:.0f}',
|
||||
)
|
||||
|
||||
keyboard_buttons: list[list[InlineKeyboardButton]] = []
|
||||
if payment_url:
|
||||
keyboard_buttons.append([InlineKeyboardButton(text=pay_button_text, url=payment_url)])
|
||||
keyboard_buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||
|
||||
if payment_url:
|
||||
response_text = texts.t(
|
||||
'DONUT_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже для перехода к оплате.\n'
|
||||
'После подтверждения платежа баланс будет пополнен автоматически.',
|
||||
).format(name=name, amount=f'{amount_rub:.2f}')
|
||||
else:
|
||||
response_text = texts.t(
|
||||
'DONUT_PAYMENT_PROCESSING',
|
||||
'\U0001f4b3 <b>Платёж создан через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Платёж в обработке. Реквизиты будут отправлены отдельным сообщением.',
|
||||
).format(name=name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(response_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
else:
|
||||
await message_or_callback.answer(response_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
logger.info('Donut payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_donut_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Обрабатывает сумму, введённую пользователем для Donut."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
min_amount = settings.DONUT_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.DONUT_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}₽',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'donut')
|
||||
payment_method_type = _extract_service_type(payment_method)
|
||||
display_name = _get_display_name(payment_method)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_donut_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method_type=payment_method_type,
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
|
||||
async def _start_donut_topup_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""Стартует FSM ввода суммы для Donut."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method=payment_method)
|
||||
|
||||
min_amount = settings.DONUT_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.DONUT_MAX_AMOUNT_KOPEKS // 100
|
||||
|
||||
display_name = _get_display_name(payment_method)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'DONUT_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}₽\n'
|
||||
'Максимум: {max_amount}₽',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_donut_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_donut_topup_impl(callback, db_user, state, 'donut')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_donut_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_donut_topup_impl(callback, db_user, state, 'donut_card')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_donut_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_donut_topup_impl(callback, db_user, state, 'donut_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_donut_sbp_qr_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_donut_topup_impl(callback, db_user, state, 'donut_sbp_qr')
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Handler for Etoplatezhi balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_etoplatezhi_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method_type: str | None = None,
|
||||
):
|
||||
"""
|
||||
Common logic for creating Etoplatezhi payment and sending response.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_etoplatezhi_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_etoplatezhi_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'\U0001f4b3 Оплатить {amount}\u20bd',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'ETOPLATEZHI_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}\u20bd</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info('Etoplatezhi payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_etoplatezhi_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}\u20bd',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}\u20bd',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'etoplatezhi')
|
||||
# etoplatezhi_sbp → 'sbp', etoplatezhi_card → 'card', etoplatezhi → None
|
||||
payment_method_type = _extract_service_type(payment_method)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_etoplatezhi_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
|
||||
ETOPLATEZHI_PAYMENT_METHODS = {'etoplatezhi', 'etoplatezhi_sbp', 'etoplatezhi_card'}
|
||||
|
||||
ETOPLATEZHI_SERVICE_MAP: dict[str, str | None] = {
|
||||
'etoplatezhi': None,
|
||||
'etoplatezhi_sbp': 'sbp',
|
||||
'etoplatezhi_card': 'card',
|
||||
}
|
||||
|
||||
|
||||
def _extract_service_type(payment_method: str) -> str | None:
|
||||
return ETOPLATEZHI_SERVICE_MAP.get(payment_method)
|
||||
|
||||
|
||||
async def _start_etoplatezhi_topup_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""Common logic for starting Etoplatezhi top-up (generic / SBP / card)."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method=payment_method)
|
||||
|
||||
min_amount = settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS // 100
|
||||
|
||||
# Choose display name based on sub-method
|
||||
if payment_method == 'etoplatezhi_sbp':
|
||||
display_name = settings.get_etoplatezhi_sbp_display_name()
|
||||
elif payment_method == 'etoplatezhi_card':
|
||||
display_name = settings.get_etoplatezhi_card_display_name()
|
||||
else:
|
||||
display_name = settings.get_etoplatezhi_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'ETOPLATEZHI_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}\u20bd\n'
|
||||
'Максимум: {max_amount}\u20bd',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_etoplatezhi_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_etoplatezhi_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_etoplatezhi_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi_card')
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Handler for Jupiter balance top-up (FPGate P2P v2.1)."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
JUPITER_PAYMENT_METHODS = {'jupiter', 'jupiter_sbp'}
|
||||
|
||||
JUPITER_SERVICE_MAP: dict[str, str | None] = {
|
||||
'jupiter': None,
|
||||
'jupiter_sbp': 'sbp',
|
||||
}
|
||||
|
||||
|
||||
def _extract_service_type(payment_method: str) -> str | None:
|
||||
return JUPITER_SERVICE_MAP.get(payment_method)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_jupiter_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method_type: str | None = None,
|
||||
):
|
||||
"""Создаёт платёж Jupiter и отправляет ссылку/QR пользователю."""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
payment_service = PaymentService()
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_jupiter_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(error_text, parse_mode='HTML')
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_jupiter_display_name()
|
||||
|
||||
pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}₽').format(
|
||||
amount=f'{amount_rub:.0f}',
|
||||
)
|
||||
|
||||
keyboard_buttons: list[list[InlineKeyboardButton]] = []
|
||||
if payment_url:
|
||||
keyboard_buttons.append([InlineKeyboardButton(text=pay_button_text, url=payment_url)])
|
||||
keyboard_buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||
|
||||
if payment_url:
|
||||
response_text = texts.t(
|
||||
'JUPITER_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже, чтобы открыть QR-код СБП и оплатить.\n'
|
||||
'Баланс будет пополнен автоматически после подтверждения платежа.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
else:
|
||||
response_text = texts.t(
|
||||
'JUPITER_PAYMENT_PROCESSING',
|
||||
'\U0001f4b3 <b>Платёж создан через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Платёж в обработке. Реквизиты будут отправлены отдельным сообщением.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(response_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
else:
|
||||
await message_or_callback.answer(response_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
logger.info('Jupiter payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_jupiter_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Обрабатывает сумму, введённую пользователем для Jupiter."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
min_amount = settings.JUPITER_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.JUPITER_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}₽',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'jupiter')
|
||||
payment_method_type = _extract_service_type(payment_method)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_jupiter_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
|
||||
async def _start_jupiter_topup_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""Стартует FSM ввода суммы для Jupiter."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method=payment_method)
|
||||
|
||||
min_amount = settings.JUPITER_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.JUPITER_MAX_AMOUNT_KOPEKS // 100
|
||||
|
||||
if payment_method == 'jupiter_sbp':
|
||||
display_name = settings.get_jupiter_sbp_display_name()
|
||||
else:
|
||||
display_name = settings.get_jupiter_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'JUPITER_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}₽\n'
|
||||
'Максимум: {max_amount}₽',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_jupiter_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_jupiter_topup_impl(callback, db_user, state, 'jupiter')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_jupiter_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_jupiter_topup_impl(callback, db_user, state, 'jupiter_sbp')
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Handler for Lava balance top-up (Lava Business, gate.lava.ru)."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
LAVA_PAYMENT_METHODS = {'lava', 'lava_card', 'lava_sbp'}
|
||||
|
||||
LAVA_SERVICE_MAP: dict[str, str | None] = {
|
||||
'lava': None,
|
||||
'lava_card': 'card',
|
||||
'lava_sbp': 'sbp',
|
||||
}
|
||||
|
||||
|
||||
def _extract_service_type(payment_method: str) -> str | None:
|
||||
return LAVA_SERVICE_MAP.get(payment_method)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def _get_display_name(payment_method: str) -> str:
|
||||
if payment_method == 'lava_card':
|
||||
return settings.get_lava_card_display_name()
|
||||
if payment_method == 'lava_sbp':
|
||||
return settings.get_lava_sbp_display_name()
|
||||
return settings.get_lava_display_name()
|
||||
|
||||
|
||||
async def _create_lava_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method_type: str | None = None,
|
||||
display_name: str | None = None,
|
||||
):
|
||||
"""Создаёт инвойс Lava и отправляет ссылку пользователю."""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
payment_service = PaymentService()
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_lava_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(error_text, parse_mode='HTML')
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
name = display_name or settings.get_lava_display_name()
|
||||
|
||||
# Без URL mixin вернул бы None ещё до этого блока; здесь URL гарантирован.
|
||||
pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}₽').format(
|
||||
amount=f'{amount_rub:.0f}',
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=pay_button_text, url=payment_url)],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'LAVA_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже для перехода к оплате.\n'
|
||||
'После подтверждения платежа баланс будет пополнен автоматически.',
|
||||
).format(name=name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(response_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
else:
|
||||
await message_or_callback.answer(response_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
logger.info('Lava payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_lava_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Обрабатывает сумму для Lava."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
min_amount = settings.LAVA_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.LAVA_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}₽',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'lava')
|
||||
payment_method_type = _extract_service_type(payment_method)
|
||||
display_name = _get_display_name(payment_method)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_lava_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method_type=payment_method_type,
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
|
||||
async def _start_lava_topup_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""Стартует FSM ввода суммы для Lava."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method=payment_method)
|
||||
|
||||
min_amount = settings.LAVA_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.LAVA_MAX_AMOUNT_KOPEKS // 100
|
||||
|
||||
display_name = _get_display_name(payment_method)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'LAVA_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}₽\n'
|
||||
'Максимум: {max_amount}₽',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_lava_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_lava_topup_impl(callback, db_user, state, 'lava')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_lava_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_lava_topup_impl(callback, db_user, state, 'lava_card')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_lava_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_lava_topup_impl(callback, db_user, state, 'lava_sbp')
|
||||
@@ -170,13 +170,48 @@ async def route_payment_by_method(
|
||||
await process_overpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'aurapay':
|
||||
if payment_method in ('aurapay', 'aurapay_sbp', 'aurapay_card'):
|
||||
from .aurapay import process_aurapay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_aurapay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('etoplatezhi', 'etoplatezhi_sbp', 'etoplatezhi_card'):
|
||||
from .etoplatezhi import process_etoplatezhi_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_etoplatezhi_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('antilopay', 'antilopay_sbp', 'antilopay_card', 'antilopay_sberpay'):
|
||||
from .antilopay import process_antilopay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_antilopay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('jupiter', 'jupiter_sbp'):
|
||||
from .jupiter import process_jupiter_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_jupiter_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('donut', 'donut_card', 'donut_sbp', 'donut_sbp_qr'):
|
||||
from .donut import process_donut_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_donut_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('lava', 'lava_card', 'lava_sbp'):
|
||||
from .lava import process_lava_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_lava_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'riopay':
|
||||
from .riopay import process_riopay_payment_amount
|
||||
|
||||
@@ -768,9 +803,52 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(start_overpay_topup, F.data == 'topup_overpay')
|
||||
|
||||
from .aurapay import start_aurapay_topup
|
||||
from .aurapay import start_aurapay_card_topup, start_aurapay_sbp_topup, start_aurapay_topup
|
||||
|
||||
dp.callback_query.register(start_aurapay_topup, F.data == 'topup_aurapay')
|
||||
dp.callback_query.register(start_aurapay_sbp_topup, F.data == 'topup_aurapay_sbp')
|
||||
dp.callback_query.register(start_aurapay_card_topup, F.data == 'topup_aurapay_card')
|
||||
|
||||
from .etoplatezhi import start_etoplatezhi_card_topup, start_etoplatezhi_sbp_topup, start_etoplatezhi_topup
|
||||
|
||||
dp.callback_query.register(start_etoplatezhi_topup, F.data == 'topup_etoplatezhi')
|
||||
dp.callback_query.register(start_etoplatezhi_sbp_topup, F.data == 'topup_etoplatezhi_sbp')
|
||||
dp.callback_query.register(start_etoplatezhi_card_topup, F.data == 'topup_etoplatezhi_card')
|
||||
|
||||
from .antilopay import (
|
||||
start_antilopay_card_topup,
|
||||
start_antilopay_sberpay_topup,
|
||||
start_antilopay_sbp_topup,
|
||||
start_antilopay_topup,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_antilopay_topup, F.data == 'topup_antilopay')
|
||||
dp.callback_query.register(start_antilopay_sbp_topup, F.data == 'topup_antilopay_sbp')
|
||||
dp.callback_query.register(start_antilopay_card_topup, F.data == 'topup_antilopay_card')
|
||||
dp.callback_query.register(start_antilopay_sberpay_topup, F.data == 'topup_antilopay_sberpay')
|
||||
|
||||
from .jupiter import start_jupiter_sbp_topup, start_jupiter_topup
|
||||
|
||||
dp.callback_query.register(start_jupiter_topup, F.data == 'topup_jupiter')
|
||||
dp.callback_query.register(start_jupiter_sbp_topup, F.data == 'topup_jupiter_sbp')
|
||||
|
||||
from .donut import (
|
||||
start_donut_card_topup,
|
||||
start_donut_sbp_qr_topup,
|
||||
start_donut_sbp_topup,
|
||||
start_donut_topup,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_donut_topup, F.data == 'topup_donut')
|
||||
dp.callback_query.register(start_donut_card_topup, F.data == 'topup_donut_card')
|
||||
dp.callback_query.register(start_donut_sbp_topup, F.data == 'topup_donut_sbp')
|
||||
dp.callback_query.register(start_donut_sbp_qr_topup, F.data == 'topup_donut_sbp_qr')
|
||||
|
||||
from .lava import start_lava_card_topup, start_lava_sbp_topup, start_lava_topup
|
||||
|
||||
dp.callback_query.register(start_lava_topup, F.data == 'topup_lava')
|
||||
dp.callback_query.register(start_lava_card_topup, F.data == 'topup_lava_card')
|
||||
dp.callback_query.register(start_lava_sbp_topup, F.data == 'topup_lava_sbp')
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
|
||||
|
||||
@@ -120,8 +120,7 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
|
||||
|
||||
# Show bot link
|
||||
referral_text += (
|
||||
texts.t('REFERRAL_BOT_LINK_TITLE', '🤖 <b>Ссылка на бота:</b>')
|
||||
+ f'\n<code>{html_escape(bot_referral_link)}</code>\n'
|
||||
texts.t('REFERRAL_BOT_LINK_TITLE', '🤖 <b>Ссылка на бота:</b>') + f'\n{html_escape(bot_referral_link)}\n'
|
||||
)
|
||||
|
||||
# Show cabinet link if configured
|
||||
@@ -129,7 +128,7 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
|
||||
referral_text += (
|
||||
'\n'
|
||||
+ texts.t('REFERRAL_CABINET_LINK_TITLE', '🌐 <b>Ссылка на кабинет:</b>')
|
||||
+ f'\n<code>{html_escape(cabinet_referral_link)}</code>\n'
|
||||
+ f'\n{html_escape(cabinet_referral_link)}\n'
|
||||
)
|
||||
|
||||
referral_text += (
|
||||
@@ -551,7 +550,7 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
|
||||
'Нажмите на текст ниже, чтобы скопировать:',
|
||||
)
|
||||
+ '\n\n'
|
||||
f'<blockquote><code>{html_escape(invite_text)}</code></blockquote>'
|
||||
f'<blockquote>{html_escape(invite_text)}</blockquote>'
|
||||
),
|
||||
keyboard,
|
||||
)
|
||||
|
||||
+47
-1
@@ -49,7 +49,11 @@ from app.services.pinned_message_service import (
|
||||
get_active_pinned_message,
|
||||
)
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.referral_service import process_referral_registration, save_pending_referral
|
||||
from app.services.referral_service import (
|
||||
process_referral_registration,
|
||||
save_pending_campaign,
|
||||
save_pending_referral,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.services.web_auth_service import WEB_AUTH_TOKEN_MIN_LENGTH, link_web_auth_token
|
||||
@@ -386,6 +390,17 @@ async def _apply_campaign_bonus_if_needed(
|
||||
if not result.success:
|
||||
return None
|
||||
|
||||
# Bot-flow successfully applied the campaign — clear the Redis pending entry
|
||||
# (set in cmd_start as a fallback for the cabinet WebApp path) so it isn't
|
||||
# re-evaluated on a subsequent cabinet login.
|
||||
try:
|
||||
from app.services.referral_service import clear_pending_campaign
|
||||
|
||||
if getattr(user, 'telegram_id', None):
|
||||
await clear_pending_campaign(user.telegram_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if result.bonus_type == 'balance':
|
||||
amount_text = texts.format_price(result.balance_kopeks)
|
||||
return texts.CAMPAIGN_BONUS_BALANCE.format(
|
||||
@@ -724,6 +739,23 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
start_parameter=campaign.start_parameter,
|
||||
)
|
||||
await state.update_data(campaign_id=campaign.id)
|
||||
# Persist campaign to Redis immediately so it survives if user opens
|
||||
# miniapp/cabinet (via Telegram menu button) before completing the
|
||||
# bot registration flow. Mirrors the pending_referral mechanism.
|
||||
# Only for new users — existing users already had attribution applied.
|
||||
if not db_user:
|
||||
try:
|
||||
await save_pending_campaign(
|
||||
message.from_user.id,
|
||||
campaign.start_parameter,
|
||||
campaign.id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
'Failed to persist pending campaign',
|
||||
campaign_id=campaign.id,
|
||||
error=exc,
|
||||
)
|
||||
if campaign.partner_user_id:
|
||||
await state.update_data(referrer_id=campaign.partner_user_id)
|
||||
logger.info(
|
||||
@@ -2416,6 +2448,20 @@ async def required_sub_channel_check(
|
||||
campaign_id=campaign.id,
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
)
|
||||
# Mirror save in Redis so cabinet WebApp auth can pick it up
|
||||
# if user opens miniapp before completing registration.
|
||||
try:
|
||||
await save_pending_campaign(
|
||||
query.from_user.id,
|
||||
campaign.start_parameter,
|
||||
campaign.id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
'Failed to persist pending campaign after channel check',
|
||||
campaign_id=campaign.id,
|
||||
error=exc,
|
||||
)
|
||||
else:
|
||||
state_data['referral_code'] = pending_start_payload
|
||||
logger.info(
|
||||
|
||||
@@ -101,6 +101,18 @@ async def toggle_autopay(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
enable = callback.data.startswith('autopay_enable')
|
||||
|
||||
if enable:
|
||||
# Trial subscriptions cannot use autopay
|
||||
if subscription.is_trial or subscription.is_trial is None:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'AUTOPAY_NOT_AVAILABLE_TRIAL',
|
||||
'Автоплатеж недоступен для пробных подписок.',
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Classic subscriptions cannot use autopay when tariff mode is enabled
|
||||
if settings.is_tariffs_mode() and not subscription.tariff_id:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import html as html_mod
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
@@ -405,11 +406,37 @@ async def get_apps_for_platform_async(device_type: str, language: str = 'ru') ->
|
||||
|
||||
def normalize_app(app: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize Remnawave app dict to a unified format with blocks."""
|
||||
|
||||
# Extract urlScheme from blocks if not present at root level
|
||||
url_scheme = app.get('urlScheme', '')
|
||||
|
||||
if not url_scheme:
|
||||
# Try to extract from subscriptionLink button in blocks
|
||||
blocks = app.get('blocks', [])
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
buttons = block.get('buttons', [])
|
||||
for btn in buttons:
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
if btn.get('type') == 'subscriptionLink':
|
||||
link = btn.get('link', '') or btn.get('url', '')
|
||||
if '{{SUBSCRIPTION_LINK}}' in link:
|
||||
url_scheme = link.split('{{SUBSCRIPTION_LINK}}')[0]
|
||||
break
|
||||
if url_scheme:
|
||||
break
|
||||
|
||||
# Validate extracted scheme contains ://
|
||||
if url_scheme and '://' not in url_scheme:
|
||||
url_scheme = ''
|
||||
|
||||
return {
|
||||
'id': app.get('id', app.get('name', 'unknown')),
|
||||
'name': app.get('name', ''),
|
||||
'isFeatured': app.get('featured', app.get('isFeatured', False)),
|
||||
'urlScheme': app.get('urlScheme', ''),
|
||||
'urlScheme': url_scheme,
|
||||
'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False),
|
||||
'blocks': app.get('blocks', []),
|
||||
'_raw': app,
|
||||
@@ -545,7 +572,7 @@ def get_traffic_switch_keyboard(
|
||||
# Считаем по дням (как в кабинете и подтверждении)
|
||||
if subscription_end_date:
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription_end_date - now).days)
|
||||
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
|
||||
price_multiplier = days_left / 30
|
||||
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
|
||||
else:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import html
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from aiogram import types
|
||||
@@ -266,7 +267,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
|
||||
logger.info('🔧 Добавлено: Удалено', added=added, removed=removed)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
days_to_pay = max(1, (subscription.end_date - now).days)
|
||||
days_to_pay = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
|
||||
|
||||
period_hint_days = days_to_pay if days_to_pay > 0 else None
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import html as html_mod
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from aiogram import types
|
||||
@@ -343,7 +344,7 @@ async def confirm_change_devices(
|
||||
|
||||
# Считаем стоимость по оставшимся дням подписки
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription.end_date - now).days)
|
||||
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
|
||||
period_hint_days = days_left
|
||||
|
||||
devices_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
@@ -572,7 +573,7 @@ async def execute_change_devices(
|
||||
chargeable_devices = devices_difference
|
||||
|
||||
devices_price_per_month = chargeable_devices * price_per_device
|
||||
days_left = max(1, (subscription.end_date - datetime.now(UTC)).days)
|
||||
days_left = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
|
||||
devices_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'devices',
|
||||
@@ -601,7 +602,7 @@ async def execute_change_devices(
|
||||
)
|
||||
return
|
||||
|
||||
charged_days = max(1, (subscription.end_date - datetime.now(UTC)).days)
|
||||
charged_days = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
@@ -1253,7 +1254,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
if is_daily_tariff:
|
||||
# Для суточных тарифов считаем по дням (как в кабинете)
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription.end_date - now).days)
|
||||
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
|
||||
period_hint_days = days_left
|
||||
|
||||
devices_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
@@ -1274,7 +1275,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
else:
|
||||
# Для обычных тарифов - по дням (как в кабинете)
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription.end_date - now).days)
|
||||
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
|
||||
period_hint_days = days_left
|
||||
|
||||
devices_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
|
||||
@@ -132,6 +132,16 @@ def _build_subscription_detail_keyboard(sub_id: int, sub=None) -> types.InlineKe
|
||||
if is_inactive:
|
||||
buttons.append([types.InlineKeyboardButton(text='🗑 Удалить подписку', callback_data=f'sub_del:{sub_id}')])
|
||||
|
||||
if not is_inactive and settings.is_subscription_revoke_enabled():
|
||||
buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🔄 Перевыпустить',
|
||||
callback_data=f'sr:{sub_id}',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
buttons.append([types.InlineKeyboardButton(text='◀️ К списку подписок', callback_data='my_subscriptions')])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
@@ -4141,6 +4141,17 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(handle_change_devices_menu, F.data.startswith('change_devices_menu:'))
|
||||
dp.callback_query.register(handle_device_management_menu, F.data.startswith('device_management:'))
|
||||
|
||||
# Subscription revoke (reissue)
|
||||
from app.handlers.subscription.revoke import (
|
||||
confirm_subscription_revoke,
|
||||
start_multi_revoke,
|
||||
start_subscription_revoke,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_subscription_revoke, F.data == 'subscription_revoke')
|
||||
dp.callback_query.register(confirm_subscription_revoke, F.data == 'subscription_revoke_confirm')
|
||||
dp.callback_query.register(start_multi_revoke, F.data.startswith('sr:'))
|
||||
|
||||
dp.callback_query.register(show_trial_offer, F.data == 'menu_trial')
|
||||
|
||||
dp.callback_query.register(activate_trial, F.data == 'trial_activate')
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Handler for subscription reissue (revoke + regenerate link)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InaccessibleMessage, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import get_subscription_by_id_for_user
|
||||
from app.database.models import Subscription, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_revoke_cooldown(subscription: Subscription) -> int | None:
|
||||
"""Returns remaining seconds if on cooldown, None if ready."""
|
||||
if not subscription.last_revoke_at:
|
||||
return None
|
||||
elapsed = (datetime.now(UTC) - subscription.last_revoke_at).total_seconds()
|
||||
cooldown = settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS
|
||||
if elapsed < cooldown:
|
||||
return int(cooldown - elapsed)
|
||||
return None
|
||||
|
||||
|
||||
def _build_revoke_confirm_keyboard(
|
||||
language: str,
|
||||
multi_tariff: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Build confirmation keyboard for revoke action."""
|
||||
texts = get_texts(language)
|
||||
back_callback = 'my_subscriptions' if multi_tariff else 'subscription_settings'
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('SUBSCRIPTION_REVOKE_CONFIRM_BTN', '✅ Подтвердить'),
|
||||
callback_data='subscription_revoke_confirm',
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=back_callback,
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _build_revoke_success_keyboard(
|
||||
language: str,
|
||||
multi_tariff: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Build success keyboard with connect and back buttons."""
|
||||
texts = get_texts(language)
|
||||
back_callback = 'my_subscriptions' if multi_tariff else 'menu_subscription'
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('SUBSCRIPTION_REVOKE_CONNECT_BTN', '🔗 Подключиться'),
|
||||
callback_data='subscription_connect',
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=back_callback,
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Classic mode (single subscription)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_subscription_revoke(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext | None = None,
|
||||
) -> None:
|
||||
"""Show revoke confirmation for classic single-subscription mode."""
|
||||
if isinstance(callback.message, InaccessibleMessage):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_subscription_revoke_enabled():
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_REVOKE_DISABLED', 'Перевыпуск подписки недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
subscription = db_user.subscription
|
||||
if not subscription or not subscription.is_active:
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Check cooldown
|
||||
remaining = _check_revoke_cooldown(subscription)
|
||||
if remaining is not None:
|
||||
minutes = remaining // 60
|
||||
seconds = remaining % 60
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'SUBSCRIPTION_REVOKE_COOLDOWN',
|
||||
'⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.',
|
||||
).format(minutes=minutes, seconds=seconds),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'SUBSCRIPTION_REVOKE_WARNING',
|
||||
(
|
||||
'⚠️ <b>Перевыпуск подписки</b>\n\n'
|
||||
'Это действие:\n'
|
||||
'• Сгенерирует новую ссылку подключения\n'
|
||||
'• Сбросит все подключённые устройства\n'
|
||||
'• Старая ссылка перестанет работать\n\n'
|
||||
'Продолжить?'
|
||||
),
|
||||
),
|
||||
reply_markup=_build_revoke_confirm_keyboard(db_user.language, multi_tariff=False),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def confirm_subscription_revoke(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext | None = None,
|
||||
) -> None:
|
||||
"""Execute revoke for classic or multi-tariff mode (uses FSM state for multi)."""
|
||||
if isinstance(callback.message, InaccessibleMessage):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_subscription_revoke_enabled():
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_REVOKE_DISABLED', 'Перевыпуск подписки недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Determine subscription: multi-tariff via FSM state or classic via db_user
|
||||
is_multi = False
|
||||
subscription: Subscription | None = None
|
||||
|
||||
if state:
|
||||
data = await state.get_data()
|
||||
revoke_sub_id = data.get('revoke_sub_id')
|
||||
if revoke_sub_id is not None:
|
||||
is_multi = True
|
||||
subscription = await get_subscription_by_id_for_user(db, revoke_sub_id, db_user.id)
|
||||
if not subscription:
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if subscription is None:
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not subscription or not subscription.is_active:
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# TOCTOU protection: re-check cooldown
|
||||
remaining = _check_revoke_cooldown(subscription)
|
||||
if remaining is not None:
|
||||
minutes = remaining // 60
|
||||
seconds = remaining % 60
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'SUBSCRIPTION_REVOKE_COOLDOWN',
|
||||
'⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.',
|
||||
).format(minutes=minutes, seconds=seconds),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Answer callback BEFORE heavy operation
|
||||
await callback.answer()
|
||||
|
||||
# Execute revoke
|
||||
sub_service = SubscriptionService()
|
||||
new_url = await sub_service.revoke_subscription(db, subscription)
|
||||
|
||||
if not new_url:
|
||||
await callback.message.edit_text(
|
||||
texts.t('SUBSCRIPTION_REVOKE_ERROR', '❌ Ошибка при перевыпуске подписки. Попробуйте позже.'),
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')],
|
||||
]
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
# Update cooldown timestamp
|
||||
subscription.last_revoke_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Subscription revoked successfully',
|
||||
user_id=db_user.id,
|
||||
subscription_id=subscription.id,
|
||||
is_multi=is_multi,
|
||||
)
|
||||
|
||||
# Clean up FSM state
|
||||
if state and is_multi:
|
||||
await state.update_data(revoke_sub_id=None)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'SUBSCRIPTION_REVOKE_SUCCESS',
|
||||
(
|
||||
'✅ <b>Подписка перевыпущена!</b>\n\n'
|
||||
'Новая ссылка подключения готова. '
|
||||
'Старая ссылка больше не действительна.\n\n'
|
||||
'Все устройства были отключены.'
|
||||
),
|
||||
),
|
||||
reply_markup=_build_revoke_success_keyboard(db_user.language, multi_tariff=is_multi),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-tariff mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_multi_revoke(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
"""Show revoke confirmation for multi-tariff mode (callback_data = 'sr:{sub_id}')."""
|
||||
if isinstance(callback.message, InaccessibleMessage):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_subscription_revoke_enabled():
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_REVOKE_DISABLED', 'Перевыпуск подписки недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract sub_id from callback_data
|
||||
parts = (callback.data or '').split(':')
|
||||
if len(parts) < 2:
|
||||
await callback.answer('Неверный формат', show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
sub_id = int(parts[1])
|
||||
except (ValueError, TypeError):
|
||||
await callback.answer('Неверный формат', show_alert=True)
|
||||
return
|
||||
|
||||
# Validate ownership (IDOR protection)
|
||||
subscription = await get_subscription_by_id_for_user(db, sub_id, db_user.id)
|
||||
if not subscription:
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if not subscription.is_active:
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Check cooldown
|
||||
remaining = _check_revoke_cooldown(subscription)
|
||||
if remaining is not None:
|
||||
minutes = remaining // 60
|
||||
seconds = remaining % 60
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'SUBSCRIPTION_REVOKE_COOLDOWN',
|
||||
'⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.',
|
||||
).format(minutes=minutes, seconds=seconds),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Store sub_id in FSM state for the confirmation handler
|
||||
await state.update_data(revoke_sub_id=sub_id)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'SUBSCRIPTION_REVOKE_WARNING',
|
||||
(
|
||||
'⚠️ <b>Перевыпуск подписки</b>\n\n'
|
||||
'Это действие:\n'
|
||||
'• Сгенерирует новую ссылку подключения\n'
|
||||
'• Сбросит все подключённые устройства\n'
|
||||
'• Старая ссылка перестанет работать\n\n'
|
||||
'Продолжить?'
|
||||
),
|
||||
),
|
||||
reply_markup=_build_revoke_confirm_keyboard(db_user.language, multi_tariff=True),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
@@ -939,6 +939,13 @@ async def handle_custom_confirm(
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
|
||||
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
|
||||
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Save promo offer state before deduction (for restore on failure)
|
||||
@@ -958,11 +965,17 @@ async def handle_custom_confirm(
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
await callback.answer('Ошибка списания баланса', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Ошибка списания баланса')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error('Ошибка списания баланса при покупке кастомного тарифа', error=e, exc_info=True)
|
||||
await callback.answer('Ошибка списания баланса', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Ошибка списания баланса')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Получаем список серверов из тарифа
|
||||
@@ -1049,7 +1062,10 @@ async def handle_custom_confirm(
|
||||
price_kopeks=total_price,
|
||||
refund_error=refund_error,
|
||||
)
|
||||
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -1148,11 +1164,12 @@ async def handle_custom_confirm(
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer('Подписка оформлена!', show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка при покупке тарифа с кастомными параметрами', error=e, exc_info=True)
|
||||
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@error_handler
|
||||
@@ -1377,6 +1394,13 @@ async def confirm_tariff_purchase(
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
|
||||
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
|
||||
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Списываем баланс
|
||||
@@ -1395,11 +1419,17 @@ async def confirm_tariff_purchase(
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
await callback.answer('Ошибка списания баланса', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Ошибка списания баланса')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error('Ошибка списания баланса при покупке тарифа', error=e, exc_info=True)
|
||||
await callback.answer('Ошибка списания баланса', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Ошибка списания баланса')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Получаем список серверов из тарифа
|
||||
@@ -1457,10 +1487,12 @@ async def confirm_tariff_purchase(
|
||||
db_user.promo_offer_discount_source = saved_promo_source
|
||||
db_user.promo_offer_discount_expires_at = saved_promo_expires
|
||||
await db.commit()
|
||||
await callback.answer(
|
||||
f'Максимум подписок: {settings.get_max_active_subscriptions()}',
|
||||
show_alert=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f'❌ Максимум подписок: {settings.get_max_active_subscriptions()}'
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Create NEW subscription for this tariff (multi-tariff: new Remnawave user)
|
||||
@@ -1537,7 +1569,10 @@ async def confirm_tariff_purchase(
|
||||
reason='Возврат: тариф уже активен',
|
||||
error=refund_error,
|
||||
)
|
||||
await callback.answer('У вас уже есть активная подписка на этот тариф', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ У вас уже есть активная подписка на этот тариф')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error('Ошибка создания/продления подписки при покупке тарифа', error=e, exc_info=True)
|
||||
@@ -1581,7 +1616,10 @@ async def confirm_tariff_purchase(
|
||||
reason='Возврат: ошибка покупки тарифа',
|
||||
error=refund_error,
|
||||
)
|
||||
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Обновляем пользователя в Remnawave
|
||||
@@ -1686,7 +1724,6 @@ async def confirm_tariff_purchase(
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer('Подписка оформлена!', show_alert=True)
|
||||
|
||||
|
||||
# ==================== Покупка суточного тарифа ====================
|
||||
@@ -1741,6 +1778,13 @@ async def confirm_daily_tariff_purchase(
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
|
||||
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
|
||||
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
@@ -1754,11 +1798,17 @@ async def confirm_daily_tariff_purchase(
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
await callback.answer('Ошибка списания баланса', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Ошибка списания баланса')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error('Ошибка списания баланса при покупке суточного тарифа', error=e, exc_info=True)
|
||||
await callback.answer('Ошибка списания баланса', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Ошибка списания баланса')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Получаем список серверов из тарифа
|
||||
@@ -1864,7 +1914,10 @@ async def confirm_daily_tariff_purchase(
|
||||
price_kopeks=final_daily_price,
|
||||
refund_error=refund_error,
|
||||
)
|
||||
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Обновляем пользователя в Remnawave
|
||||
@@ -1964,7 +2017,6 @@ async def confirm_daily_tariff_purchase(
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer('Подписка оформлена!', show_alert=True)
|
||||
|
||||
|
||||
# ==================== Продление по тарифу ====================
|
||||
@@ -2364,6 +2416,13 @@ async def confirm_tariff_extend(
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
|
||||
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
|
||||
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
@@ -2377,7 +2436,10 @@ async def confirm_tariff_extend(
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
await callback.answer('Ошибка списания баланса', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Ошибка списания баланса')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Запоминаем, был ли триал ДО продления
|
||||
@@ -2487,11 +2549,12 @@ async def confirm_tariff_extend(
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer('Подписка продлена!', show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка при продлении тарифа', error=e, exc_info=True)
|
||||
await callback.answer('Произошла ошибка при продлении подписки', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Произошла ошибка при продлении подписки')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ==================== Переключение тарифов ====================
|
||||
@@ -3031,6 +3094,13 @@ async def confirm_tariff_switch(
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
|
||||
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
|
||||
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
@@ -3044,7 +3114,10 @@ async def confirm_tariff_switch(
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
await callback.answer('Ошибка списания баланса', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Ошибка списания баланса')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Получаем список серверов из тарифа
|
||||
@@ -3198,11 +3271,13 @@ async def confirm_tariff_switch(
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer('Тариф изменён!', show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка при переключении тарифа', error=e, exc_info=True)
|
||||
await callback.answer('Произошла ошибка при переключении тарифа', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Произошла ошибка при переключении тарифа')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ==================== Смена на суточный тариф ====================
|
||||
@@ -3276,6 +3351,13 @@ async def confirm_daily_tariff_switch(
|
||||
await callback.answer('Понижение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
|
||||
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
|
||||
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
@@ -3289,7 +3371,10 @@ async def confirm_daily_tariff_switch(
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
await callback.answer('Ошибка списания баланса', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Ошибка списания баланса')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Получаем список серверов из тарифа
|
||||
@@ -3445,7 +3530,6 @@ async def confirm_daily_tariff_switch(
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer('Тариф изменён!', show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка при смене на суточный тариф', error=e, exc_info=True)
|
||||
@@ -3478,7 +3562,10 @@ async def confirm_daily_tariff_switch(
|
||||
price_kopeks=final_daily_price,
|
||||
refund_error=refund_error,
|
||||
)
|
||||
await callback.answer('Произошла ошибка при смене тарифа', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Произошла ошибка при смене тарифа')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ==================== Мгновенное переключение тарифов (без выбора периода) ====================
|
||||
@@ -3976,6 +4063,13 @@ async def confirm_instant_switch(
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
|
||||
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
|
||||
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
@@ -3991,7 +4085,10 @@ async def confirm_instant_switch(
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
await callback.answer('Ошибка списания баланса', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Ошибка списания баланса')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Получаем список серверов из нового тарифа
|
||||
@@ -4058,7 +4155,10 @@ async def confirm_instant_switch(
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
await callback.answer('❌ Недостаточно средств', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Недостаточно средств')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
await create_transaction(
|
||||
db,
|
||||
@@ -4232,11 +4332,13 @@ async def confirm_instant_switch(
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer('Тариф изменён!', show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка при мгновенном переключении тарифа', error=e, exc_info=True)
|
||||
await callback.answer('Произошла ошибка при переключении тарифа', show_alert=True)
|
||||
try:
|
||||
await callback.message.edit_text('❌ Произошла ошибка при переключении тарифа')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def return_to_saved_tariff_cart(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from aiogram import types
|
||||
@@ -807,7 +808,7 @@ async def confirm_switch_traffic(
|
||||
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
days_remaining = max(1, (subscription.end_date - now).days)
|
||||
days_remaining = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
|
||||
period_hint_days = days_remaining if days_remaining > 0 else None
|
||||
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
@@ -911,7 +912,7 @@ async def execute_switch_traffic(
|
||||
base_traffic = current_traffic - purchased_traffic
|
||||
old_price_per_month = settings.get_traffic_price(base_traffic)
|
||||
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
|
||||
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
|
||||
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
|
||||
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'traffic',
|
||||
@@ -936,7 +937,7 @@ async def execute_switch_traffic(
|
||||
await callback.answer('⚠️ Ошибка списания средств', show_alert=True)
|
||||
return
|
||||
|
||||
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
|
||||
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
|
||||
+278
-11
@@ -1,3 +1,4 @@
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
@@ -7,6 +8,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import PERIOD_PRICES, settings
|
||||
from app.database.models import User
|
||||
from app.handlers.subscription.common import (
|
||||
build_redirect_link,
|
||||
create_deep_link,
|
||||
get_localized_value,
|
||||
resolve_button_url,
|
||||
)
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
@@ -1843,7 +1850,35 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_aurapay_enabled():
|
||||
if settings.is_aurapay_sbp_enabled():
|
||||
sbp_name = settings.get_aurapay_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_AURAPAY_SBP', f'📱 {sbp_name}'),
|
||||
callback_data=_build_callback('aurapay_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_aurapay_card_enabled():
|
||||
card_name = settings.get_aurapay_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_AURAPAY_CARD', f'💳 {card_name}'),
|
||||
callback_data=_build_callback('aurapay_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if (
|
||||
settings.is_aurapay_enabled()
|
||||
and not settings.is_aurapay_sbp_enabled()
|
||||
and not settings.is_aurapay_card_enabled()
|
||||
):
|
||||
aurapay_name = settings.get_aurapay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
@@ -1855,6 +1890,212 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_etoplatezhi_sbp_enabled():
|
||||
sbp_name = settings.get_etoplatezhi_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ETOPLATEZHI_SBP', f'📱 {sbp_name}'),
|
||||
callback_data=_build_callback('etoplatezhi_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_etoplatezhi_card_enabled():
|
||||
card_name = settings.get_etoplatezhi_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ETOPLATEZHI_CARD', f'💳 {card_name}'),
|
||||
callback_data=_build_callback('etoplatezhi_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if (
|
||||
settings.is_etoplatezhi_enabled()
|
||||
and not settings.is_etoplatezhi_sbp_enabled()
|
||||
and not settings.is_etoplatezhi_card_enabled()
|
||||
):
|
||||
etoplatezhi_name = settings.get_etoplatezhi_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ETOPLATEZHI', f'💳 {etoplatezhi_name}'),
|
||||
callback_data=_build_callback('etoplatezhi'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_antilopay_sbp_enabled():
|
||||
sbp_name = settings.get_antilopay_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ANTILOPAY_SBP', f'📱 {sbp_name}'),
|
||||
callback_data=_build_callback('antilopay_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_antilopay_card_enabled():
|
||||
card_name = settings.get_antilopay_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ANTILOPAY_CARD', f'💳 {card_name}'),
|
||||
callback_data=_build_callback('antilopay_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_antilopay_sberpay_enabled():
|
||||
sberpay_name = settings.get_antilopay_sberpay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ANTILOPAY_SBERPAY', f'💳 {sberpay_name}'),
|
||||
callback_data=_build_callback('antilopay_sberpay'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if (
|
||||
settings.is_antilopay_enabled()
|
||||
and not settings.is_antilopay_sbp_enabled()
|
||||
and not settings.is_antilopay_card_enabled()
|
||||
and not settings.is_antilopay_sberpay_enabled()
|
||||
):
|
||||
antilopay_name = settings.get_antilopay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ANTILOPAY', f'💳 {antilopay_name}'),
|
||||
callback_data=_build_callback('antilopay'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_jupiter_sbp_enabled():
|
||||
jupiter_sbp_name = settings.get_jupiter_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_JUPITER_SBP', f'📱 {jupiter_sbp_name}'),
|
||||
callback_data=_build_callback('jupiter_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_jupiter_enabled() and not settings.is_jupiter_sbp_enabled():
|
||||
jupiter_name = settings.get_jupiter_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_JUPITER', f'🪐 {jupiter_name}'),
|
||||
callback_data=_build_callback('jupiter'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_donut_card_enabled():
|
||||
donut_card_name = settings.get_donut_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_DONUT_CARD', f'💳 {donut_card_name}'),
|
||||
callback_data=_build_callback('donut_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_donut_sbp_enabled():
|
||||
donut_sbp_name = settings.get_donut_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_DONUT_SBP', f'📱 {donut_sbp_name}'),
|
||||
callback_data=_build_callback('donut_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_donut_sbp_qr_enabled():
|
||||
donut_qr_name = settings.get_donut_sbp_qr_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_DONUT_SBP_QR', f'🏦 {donut_qr_name}'),
|
||||
callback_data=_build_callback('donut_sbp_qr'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if (
|
||||
settings.is_donut_enabled()
|
||||
and not settings.is_donut_card_enabled()
|
||||
and not settings.is_donut_sbp_enabled()
|
||||
and not settings.is_donut_sbp_qr_enabled()
|
||||
):
|
||||
donut_name = settings.get_donut_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_DONUT', f'🍩 {donut_name}'),
|
||||
callback_data=_build_callback('donut'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_lava_card_enabled():
|
||||
lava_card_name = settings.get_lava_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_LAVA_CARD', f'💳 {lava_card_name}'),
|
||||
callback_data=_build_callback('lava_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_lava_sbp_enabled():
|
||||
lava_sbp_name = settings.get_lava_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_LAVA_SBP', f'📱 {lava_sbp_name}'),
|
||||
callback_data=_build_callback('lava_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_lava_enabled() and not settings.is_lava_card_enabled() and not settings.is_lava_sbp_enabled():
|
||||
lava_name = settings.get_lava_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_LAVA', f'🌋 {lava_name}'),
|
||||
callback_data=_build_callback('lava'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_support_topup_enabled():
|
||||
keyboard.append(
|
||||
[
|
||||
@@ -2169,7 +2410,7 @@ def get_add_traffic_keyboard(
|
||||
# Считаем по дням (как в кабинете и подтверждении)
|
||||
if subscription_end_date:
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription_end_date - now).days)
|
||||
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
|
||||
price_multiplier = days_left / 30
|
||||
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
|
||||
else:
|
||||
@@ -2311,7 +2552,7 @@ def get_change_devices_keyboard(
|
||||
# Считаем по дням (как в кабинете и подтверждении)
|
||||
if subscription_end_date:
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription_end_date - now).days)
|
||||
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
|
||||
price_multiplier = days_left / 30
|
||||
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
|
||||
else:
|
||||
@@ -2473,7 +2714,7 @@ def get_manage_countries_keyboard(
|
||||
# Считаем по дням (как в кабинете и подтверждении)
|
||||
if subscription_end_date:
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription_end_date - now).days)
|
||||
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
|
||||
price_multiplier = days_left / 30
|
||||
logger.info(
|
||||
'🔍 Расчет для управления странами: осталось дней до',
|
||||
@@ -2553,9 +2794,6 @@ def get_device_selection_keyboard(
|
||||
platforms: list[dict] | None = None,
|
||||
sub_id: int | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
from app.handlers.subscription.common import get_localized_value
|
||||
|
||||
texts = get_texts(language)
|
||||
back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription'
|
||||
|
||||
@@ -2608,8 +2846,6 @@ def get_connection_guide_keyboard(
|
||||
has_other_apps: bool = False,
|
||||
sub_id: int | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.handlers.subscription.common import create_deep_link, get_localized_value, resolve_button_url
|
||||
|
||||
texts = get_texts(language)
|
||||
back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription'
|
||||
|
||||
@@ -2622,6 +2858,10 @@ def get_connection_guide_keyboard(
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
btn_type = btn.get('type', '')
|
||||
# Support both 'external' and 'externalLink' for backward compatibility
|
||||
if btn_type == 'external':
|
||||
btn_type = 'externalLink'
|
||||
|
||||
btn_text = btn.get('text', {})
|
||||
if isinstance(btn_text, dict):
|
||||
btn_text = get_localized_value(btn_text, language)
|
||||
@@ -2643,9 +2883,26 @@ def get_connection_guide_keyboard(
|
||||
]
|
||||
)
|
||||
elif btn_type == 'subscriptionLink':
|
||||
# First try to resolve the button's URL template
|
||||
url = resolved_url or resolve_button_url(btn_url, subscription_url)
|
||||
deep_link = create_deep_link(app.get('_raw', app), subscription_url)
|
||||
final_url = deep_link or url or subscription_url
|
||||
|
||||
# If button has no template, try deep link
|
||||
if not btn_url or '{{SUBSCRIPTION_LINK}}' not in btn_url:
|
||||
deep_link = create_deep_link(app.get('_raw', app), subscription_url)
|
||||
final_url = deep_link or url or subscription_url
|
||||
else:
|
||||
final_url = url or subscription_url
|
||||
|
||||
# Telegram doesn't support custom URL schemes — wrap with redirect
|
||||
if final_url and not final_url.startswith(('http://', 'https://')):
|
||||
template = settings.get_happ_cryptolink_redirect_template()
|
||||
if template:
|
||||
wrapped_url = build_redirect_link(final_url, template)
|
||||
if wrapped_url:
|
||||
final_url = wrapped_url
|
||||
else:
|
||||
final_url = subscription_url
|
||||
|
||||
if final_url:
|
||||
keyboard.append(
|
||||
[
|
||||
@@ -2975,6 +3232,16 @@ def get_updated_subscription_settings_keyboard(
|
||||
]
|
||||
)
|
||||
|
||||
if settings.is_subscription_revoke_enabled():
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('SUBSCRIPTION_REVOKE_BTN', '🔄 Перевыпустить подписку'),
|
||||
callback_data='subscription_revoke',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
@@ -1758,5 +1758,15 @@
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Torrent detected</b>\n\nTorrent traffic was detected on your connection{tariff_label}. Using torrents may result in subscription restrictions.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Close",
|
||||
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Traffic Warning</b>\n\nUsed: {used:.1f} / {limit} GB ({percent:.0f}%)\n\nYour traffic limit is almost reached.",
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>Low Balance</b>\n\nYour balance: {balance} ₽\nNotification threshold: {threshold} ₽\n\nTop up your balance to ensure automatic subscription renewal."
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>Low Balance</b>\n\nYour balance: {balance} ₽\nNotification threshold: {threshold} ₽\n\nTop up your balance to ensure automatic subscription renewal.",
|
||||
|
||||
"SUBSCRIPTION_REVOKE_BTN": "🔄 Reissue Subscription",
|
||||
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ Reissue Subscription",
|
||||
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>Reissue Subscription</b>\n\nThis action will:\n• Generate a new connection link\n• Disconnect all devices\n• The old link will stop working\n\nContinue?",
|
||||
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Confirm",
|
||||
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>Subscription reissued!</b>\n\nYour new connection link is ready. The old link is no longer valid.\n\nAll devices have been disconnected.",
|
||||
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Reissue will be available in {minutes} min {seconds} sec.",
|
||||
"SUBSCRIPTION_REVOKE_DISABLED": "Subscription reissue is not available",
|
||||
"SUBSCRIPTION_REVOKE_ERROR": "❌ Error reissuing subscription. Please try again later.",
|
||||
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Connect"
|
||||
}
|
||||
@@ -1779,5 +1779,15 @@
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>تورنت شناسایی شد</b>\n\nترافیک تورنت در اتصال{tariff_label} شما شناسایی شد. استفاده از تورنت ممکن است منجر به محدودیت اشتراک شود.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ بستن",
|
||||
"TRAFFIC_WARNING_ALERT": "⚠️ <b>هشدار ترافیک</b>\n\nاستفاده شده: {used:.1f} / {limit} گیگابایت ({percent:.0f}%)\n\nحد ترافیک شما تقریباً تمام شده است.",
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>موجودی کم</b>\n\nموجودی شما: {balance} ₽\nآستانه اطلاعرسانی: {threshold} ₽\n\nموجودی خود را شارژ کنید تا تمدید خودکار اشتراک با موفقیت انجام شود."
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>موجودی کم</b>\n\nموجودی شما: {balance} ₽\nآستانه اطلاعرسانی: {threshold} ₽\n\nموجودی خود را شارژ کنید تا تمدید خودکار اشتراک با موفقیت انجام شود.",
|
||||
|
||||
"SUBSCRIPTION_REVOKE_BTN": "🔄 صدور مجدد اشتراک",
|
||||
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ صدور مجدد اشتراک",
|
||||
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>صدور مجدد اشتراک</b>\n\nاین عمل:\n• لینک اتصال جدیدی تولید میکند\n• تمام دستگاههای متصل را قطع میکند\n• لینک قدیمی دیگر کار نخواهد کرد\n\nادامه میدهید؟",
|
||||
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ تأیید",
|
||||
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>اشتراک مجدداً صادر شد!</b>\n\nلینک اتصال جدید آماده است. لینک قدیمی دیگر معتبر نیست.\n\nتمام دستگاهها قطع شدند.",
|
||||
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ صدور مجدد {minutes} دقیقه و {seconds} ثانیه دیگر در دسترس خواهد بود.",
|
||||
"SUBSCRIPTION_REVOKE_DISABLED": "صدور مجدد اشتراک در دسترس نیست",
|
||||
"SUBSCRIPTION_REVOKE_ERROR": "❌ خطا در صدور مجدد اشتراک. لطفاً بعداً دوباره امتحان کنید.",
|
||||
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 اتصال"
|
||||
}
|
||||
@@ -1779,5 +1779,15 @@
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Обнаружен торрент</b>\n\nВ вашем подключении{tariff_label} обнаружен торрент-трафик. Использование торрентов может привести к ограничению подписки.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть",
|
||||
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Предупреждение о трафике</b>\n\nИспользовано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш лимит трафика почти исчерпан.",
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>Низкий баланс</b>\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно."
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>Низкий баланс</b>\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно.",
|
||||
|
||||
"SUBSCRIPTION_REVOKE_BTN": "🔄 Перевыпустить подписку",
|
||||
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ Перевыпуск подписки",
|
||||
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>Перевыпуск подписки</b>\n\nЭто действие:\n• Сгенерирует новую ссылку подключения\n• Сбросит все подключённые устройства\n• Старая ссылка перестанет работать\n\nПродолжить?",
|
||||
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Подтвердить",
|
||||
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>Подписка перевыпущена!</b>\n\nНовая ссылка подключения готова. Старая ссылка больше не действительна.\n\nВсе устройства были отключены.",
|
||||
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.",
|
||||
"SUBSCRIPTION_REVOKE_DISABLED": "Перевыпуск подписки недоступен",
|
||||
"SUBSCRIPTION_REVOKE_ERROR": "❌ Ошибка при перевыпуске подписки. Попробуйте позже.",
|
||||
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Подключиться"
|
||||
}
|
||||
@@ -1650,5 +1650,15 @@
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Виявлено торент</b>\n\nУ вашому підключенні{tariff_label} виявлено торент-трафік. Використання торентів може призвести до обмеження підписки.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрити",
|
||||
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Попередження про трафік</b>\n\nВикористано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш ліміт трафіку майже вичерпаний.",
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>Низький баланс</b>\n\nВаш баланс: {balance} ₽\nПоріг сповіщення: {threshold} ₽\n\nПоповніть баланс, щоб автопродовження підписки пройшло успішно."
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>Низький баланс</b>\n\nВаш баланс: {balance} ₽\nПоріг сповіщення: {threshold} ₽\n\nПоповніть баланс, щоб автопродовження підписки пройшло успішно.",
|
||||
|
||||
"SUBSCRIPTION_REVOKE_BTN": "🔄 Перевипустити підписку",
|
||||
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ Перевипуск підписки",
|
||||
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>Перевипуск підписки</b>\n\nЦя дія:\n• Згенерує нове посилання підключення\n• Скине всі підключені пристрої\n• Старе посилання перестане працювати\n\nПродовжити?",
|
||||
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Підтвердити",
|
||||
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>Підписку перевипущено!</b>\n\nНове посилання підключення готове. Старе посилання більше не дійсне.\n\nВсі пристрої були відключені.",
|
||||
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Перевипуск буде доступний через {minutes} хв. {seconds} сек.",
|
||||
"SUBSCRIPTION_REVOKE_DISABLED": "Перевипуск підписки недоступний",
|
||||
"SUBSCRIPTION_REVOKE_ERROR": "❌ Помилка при перевипуску підписки. Спробуйте пізніше.",
|
||||
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Підключитися"
|
||||
}
|
||||
@@ -1648,5 +1648,15 @@
|
||||
"BALANCE_TOPPED_UP_CART_SUFFICIENT": "✅ 余额已充值 {amount}!\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n余额足够完成订购。",
|
||||
"BALANCE_TOPPED_UP_CART_INSUFFICIENT": "✅ 余额已充值 {amount}!\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n还差:{missing}",
|
||||
"TRAFFIC_WARNING_ALERT": "⚠️ <b>流量警告</b>\n\n已使用:{used:.1f} / {limit} GB ({percent:.0f}%)\n\n您的流量限制即将用完。",
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>余额不足</b>\n\n您的余额:{balance} ₽\n通知阈值:{threshold} ₽\n\n请充值以确保订阅自动续费成功。"
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>余额不足</b>\n\n您的余额:{balance} ₽\n通知阈值:{threshold} ₽\n\n请充值以确保订阅自动续费成功。",
|
||||
|
||||
"SUBSCRIPTION_REVOKE_BTN": "🔄 重新签发订阅",
|
||||
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ 重新签发订阅",
|
||||
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>重新签发订阅</b>\n\n此操作将:\n• 生成新的连接链接\n• 断开所有已连接的设备\n• 旧链接将失效\n\n是否继续?",
|
||||
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ 确认",
|
||||
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>订阅已重新签发!</b>\n\n新的连接链接已准备就绪。旧链接已失效。\n\n所有设备已断开连接。",
|
||||
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ 重新签发将在 {minutes} 分 {seconds} 秒后可用。",
|
||||
"SUBSCRIPTION_REVOKE_DISABLED": "订阅重新签发不可用",
|
||||
"SUBSCRIPTION_REVOKE_ERROR": "❌ 重新签发订阅时出错。请稍后重试。",
|
||||
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 连接"
|
||||
}
|
||||
@@ -67,6 +67,12 @@ class AdminNotificationService:
|
||||
NotificationCategory.TICKETS: self.ticket_topic_id,
|
||||
}
|
||||
|
||||
# Per-category enabled flags (default True — backwards compatible)
|
||||
self.category_enabled: dict[NotificationCategory, bool] = {}
|
||||
for cat in NotificationCategory:
|
||||
key = f'ADMIN_NOTIFICATIONS_{cat.value.upper()}_ENABLED'
|
||||
self.category_enabled[cat] = getattr(settings, key, True)
|
||||
|
||||
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: int | None) -> str:
|
||||
if not referred_by_id:
|
||||
return 'Нет'
|
||||
@@ -1266,6 +1272,11 @@ class AdminNotificationService:
|
||||
logger.warning('ADMIN_NOTIFICATIONS_CHAT_ID не настроен')
|
||||
return False
|
||||
|
||||
# Per-category suppression
|
||||
if category and not self.category_enabled.get(category, True):
|
||||
logger.debug('Уведомление подавлено (категория отключена)', category=category.value)
|
||||
return False
|
||||
|
||||
try:
|
||||
message_kwargs = {
|
||||
'chat_id': self.chat_id,
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Сервис для работы с API Antilopay (lk.antilopay.com/api/v2)."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
from Crypto.Hash import SHA256
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Signature import pkcs1_15
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
API_BASE_URL = 'https://lk.antilopay.com/api/v2'
|
||||
|
||||
|
||||
class AntilopayAPIError(Exception):
|
||||
"""Ошибка API Antilopay."""
|
||||
|
||||
def __init__(self, status_code: int, message: str, code: int | None = None):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.api_code = code
|
||||
super().__init__(f'Antilopay API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class AntilopayService:
|
||||
"""Сервис для работы с API Antilopay."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def secret_id(self) -> str:
|
||||
return settings.ANTILOPAY_SECRET_ID or ''
|
||||
|
||||
@property
|
||||
def private_key(self) -> str:
|
||||
return settings.ANTILOPAY_PRIVATE_KEY or ''
|
||||
|
||||
@property
|
||||
def public_key(self) -> str:
|
||||
return settings.ANTILOPAY_PUBLIC_KEY or ''
|
||||
|
||||
@property
|
||||
def project_id(self) -> str:
|
||||
return settings.ANTILOPAY_PROJECT_ID or ''
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Возвращает переиспользуемую HTTP-сессию."""
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрывает HTTP-сессию."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
def _sign_request(self, json_body: str) -> str:
|
||||
"""SHA256WithRSA подпись JSON body приватным ключом.
|
||||
|
||||
Результат — base64-encoded строка.
|
||||
"""
|
||||
rsa_key = RSA.import_key(base64.b64decode(self.private_key))
|
||||
h = SHA256.new(json_body.encode('UTF-8'))
|
||||
signature = pkcs1_15.new(rsa_key).sign(h)
|
||||
return base64.b64encode(signature).decode('UTF-8')
|
||||
|
||||
def _build_headers(self, json_body: str) -> dict[str, str]:
|
||||
"""Строит заголовки запроса с подписью."""
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Apay-Secret-Id': self.secret_id,
|
||||
'X-Apay-Sign': self._sign_request(json_body),
|
||||
'X-Apay-Sign-Version': '1',
|
||||
}
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
amount_rubles: float,
|
||||
order_id: str,
|
||||
product_name: str,
|
||||
product_type: str = 'services',
|
||||
description: str = '',
|
||||
customer_email: str | None = None,
|
||||
customer_phone: str | None = None,
|
||||
prefer_methods: list[str] | None = None,
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
merchant_extra: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Создает платеж через API Antilopay.
|
||||
POST /payment/create
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'project_identificator': self.project_id,
|
||||
'amount': amount_rubles,
|
||||
'order_id': order_id,
|
||||
'currency': settings.ANTILOPAY_CURRENCY.lower(),
|
||||
'product_name': product_name,
|
||||
'product_type': product_type,
|
||||
'description': description,
|
||||
}
|
||||
|
||||
# customer — обязательное поле, нужен email или phone
|
||||
customer: dict[str, str] = {}
|
||||
if customer_email:
|
||||
customer['email'] = customer_email
|
||||
if customer_phone:
|
||||
customer['phone'] = customer_phone
|
||||
if not customer:
|
||||
# Fallback email, чтобы API не отказал
|
||||
customer['email'] = 'user@vpn.bot'
|
||||
payload['customer'] = customer
|
||||
|
||||
if prefer_methods:
|
||||
payload['prefer_methods'] = prefer_methods
|
||||
if success_url:
|
||||
payload['success_url'] = success_url
|
||||
if fail_url:
|
||||
payload['fail_url'] = fail_url
|
||||
if merchant_extra:
|
||||
payload['merchant_extra'] = merchant_extra[:255]
|
||||
|
||||
json_body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
|
||||
|
||||
logger.info(
|
||||
'Antilopay API create_payment',
|
||||
order_id=order_id,
|
||||
amount_rubles=amount_rubles,
|
||||
prefer_methods=prefer_methods,
|
||||
)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
f'{API_BASE_URL}/payment/create',
|
||||
data=json_body,
|
||||
headers=self._build_headers(json_body),
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
api_code = data.get('code')
|
||||
if response.status == 200 and api_code == 0:
|
||||
logger.info(
|
||||
'Antilopay API payment created',
|
||||
order_id=order_id,
|
||||
payment_id=data.get('payment_id'),
|
||||
payment_url=data.get('payment_url'),
|
||||
)
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'Antilopay create_payment error',
|
||||
status_code=response.status,
|
||||
api_code=api_code,
|
||||
error_msg=error_msg,
|
||||
response_data=data,
|
||||
)
|
||||
raise AntilopayAPIError(response.status, error_msg, api_code)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('Antilopay API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def check_payment(
|
||||
self,
|
||||
*,
|
||||
order_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Проверяет статус платежа.
|
||||
POST /payment/check
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'project_identificator': self.project_id,
|
||||
'order_id': order_id,
|
||||
}
|
||||
|
||||
json_body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
|
||||
|
||||
logger.info('Antilopay check_payment', order_id=order_id)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
f'{API_BASE_URL}/payment/check',
|
||||
data=json_body,
|
||||
headers=self._build_headers(json_body),
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status == 200:
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'Antilopay check_payment error',
|
||||
status_code=response.status,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
raise AntilopayAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('Antilopay API connection error', error=e)
|
||||
raise
|
||||
|
||||
def verify_callback_signature(self, raw_body: bytes, received_signature: str) -> bool:
|
||||
"""Верификация подписи callback Antilopay через SHA256WithRSA.
|
||||
|
||||
Подпись приходит в заголовке X-Apay-Callback.
|
||||
Проверяется ПУБЛИЧНЫМ ключом.
|
||||
"""
|
||||
try:
|
||||
if not received_signature:
|
||||
logger.warning('Antilopay callback: отсутствует X-Apay-Callback')
|
||||
return False
|
||||
|
||||
rsa_key = RSA.import_key(base64.b64decode(self.public_key))
|
||||
h = SHA256.new(raw_body)
|
||||
signature_bytes = base64.b64decode(received_signature)
|
||||
|
||||
pkcs1_15.new(rsa_key).verify(h, signature_bytes)
|
||||
return True
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning('Antilopay callback: invalid signature', error=str(e))
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error('Antilopay callback verify error', error=e)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
antilopay_service = AntilopayService()
|
||||
@@ -197,8 +197,9 @@ class AuraPayService:
|
||||
return False
|
||||
|
||||
# Сортируем ключи по алфавиту и конкатенируем значения
|
||||
# None → '' (PHP implode() converts null to empty string, not "None")
|
||||
sorted_keys = sorted(payload.keys())
|
||||
concatenated_values = ''.join(str(payload[key]) for key in sorted_keys)
|
||||
concatenated_values = ''.join(str(payload[key]) if payload[key] is not None else '' for key in sorted_keys)
|
||||
|
||||
expected = hmac.new(
|
||||
self.secret_key.encode('utf-8'),
|
||||
|
||||
@@ -31,6 +31,8 @@ from app.database.models import (
|
||||
AdminRole,
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
AppleTransaction,
|
||||
AuraPayPayment,
|
||||
BroadcastHistory,
|
||||
ButtonClickLog,
|
||||
CabinetRefreshToken,
|
||||
@@ -40,18 +42,27 @@ from app.database.models import (
|
||||
ContestTemplate,
|
||||
CryptoBotPayment,
|
||||
DiscountOffer,
|
||||
EmailTemplate,
|
||||
FaqPage,
|
||||
FaqSetting,
|
||||
FreekassaPayment,
|
||||
GuestPurchase,
|
||||
HeleketPayment,
|
||||
InfoPage,
|
||||
KassaAiPayment,
|
||||
LandingPage,
|
||||
MainMenuButton,
|
||||
MenuLayoutHistory,
|
||||
MonitoringLog,
|
||||
MulenPayPayment,
|
||||
NewsArticle,
|
||||
NewsCategory,
|
||||
NewsTag,
|
||||
OverpayPayment,
|
||||
Pal24Payment,
|
||||
PartnerApplication,
|
||||
PaymentMethodConfig,
|
||||
PayPearPayment,
|
||||
PinnedMessage,
|
||||
PlategaPayment,
|
||||
Poll,
|
||||
@@ -71,9 +82,13 @@ from app.database.models import (
|
||||
ReferralContestVirtualParticipant,
|
||||
ReferralEarning,
|
||||
RequiredChannel,
|
||||
RioPayPayment,
|
||||
RollyPayPayment,
|
||||
SavedPaymentMethod,
|
||||
SentNotification,
|
||||
ServerSquad,
|
||||
ServiceRule,
|
||||
SeverPayPayment,
|
||||
Squad,
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
@@ -102,6 +117,7 @@ from app.database.models import (
|
||||
WheelPrize,
|
||||
WheelSpin,
|
||||
WithdrawalRequest,
|
||||
YandexClientIdMap,
|
||||
YooKassaPayment,
|
||||
payment_method_promo_groups,
|
||||
server_squad_promo_groups,
|
||||
@@ -183,6 +199,14 @@ class BackupService:
|
||||
CloudPaymentsPayment,
|
||||
FreekassaPayment,
|
||||
KassaAiPayment,
|
||||
RioPayPayment,
|
||||
SeverPayPayment,
|
||||
PayPearPayment,
|
||||
RollyPayPayment,
|
||||
OverpayPayment,
|
||||
AuraPayPayment,
|
||||
AppleTransaction,
|
||||
SavedPaymentMethod,
|
||||
# --- Settings/content ---
|
||||
PaymentMethodConfig,
|
||||
PrivacyPolicy,
|
||||
@@ -192,6 +216,17 @@ class BackupService:
|
||||
PinnedMessage,
|
||||
MainMenuButton,
|
||||
MenuLayoutHistory,
|
||||
EmailTemplate,
|
||||
InfoPage,
|
||||
# --- News (FK: none / self-contained) ---
|
||||
NewsCategory,
|
||||
NewsTag,
|
||||
NewsArticle,
|
||||
# --- Landing / Guest purchases (FK: users, tariffs, landings) ---
|
||||
LandingPage,
|
||||
GuestPurchase,
|
||||
# --- Yandex analytics (FK: users) ---
|
||||
YandexClientIdMap,
|
||||
# --- User data (FK: users, promo_groups, subscriptions) ---
|
||||
UserPromoGroup,
|
||||
TrafficPurchase,
|
||||
@@ -1476,6 +1511,16 @@ class BackupService:
|
||||
'cloudpayments_payments',
|
||||
'freekassa_payments',
|
||||
'kassa_ai_payments',
|
||||
'riopay_payments',
|
||||
'severpay_payments',
|
||||
'paypear_payments',
|
||||
'rollypay_payments',
|
||||
'overpay_payments',
|
||||
'aurapay_payments',
|
||||
'etoplatezhi_payments',
|
||||
'antilopay_payments',
|
||||
'apple_transactions',
|
||||
'saved_payment_methods',
|
||||
# --- Content/config ---
|
||||
'pinned_messages',
|
||||
'main_menu_buttons',
|
||||
@@ -1485,6 +1530,17 @@ class BackupService:
|
||||
'privacy_policies',
|
||||
'public_offers',
|
||||
'payment_method_configs',
|
||||
'email_templates',
|
||||
'info_pages',
|
||||
# --- News ---
|
||||
'news_articles',
|
||||
'news_categories',
|
||||
'news_tags',
|
||||
# --- Landing / Guest purchases ---
|
||||
'guest_purchases',
|
||||
'landing_pages',
|
||||
# --- Yandex analytics ---
|
||||
'yandex_client_id_map',
|
||||
# --- Support ---
|
||||
'support_audit_logs',
|
||||
'ticket_messages',
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Сервис для работы с API Donut (Donut P2P, gw.donut.business)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class DonutAPIError(Exception):
|
||||
"""Ошибка API Donut."""
|
||||
|
||||
def __init__(self, status_code: int, message: str, code: str | None = None) -> None:
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.api_code = code
|
||||
super().__init__(f'Donut API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class DonutService:
|
||||
"""Клиент для Donut P2P (gw.donut.business)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return (settings.DONUT_BASE_URL or 'https://gw.donut.business').rstrip('/')
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
return settings.DONUT_TOKEN or ''
|
||||
|
||||
@property
|
||||
def secret(self) -> str:
|
||||
return settings.DONUT_SECRET or ''
|
||||
|
||||
@property
|
||||
def method_id(self) -> str | None:
|
||||
value = (settings.DONUT_METHOD_ID or '').strip()
|
||||
return value or None
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
@staticmethod
|
||||
def _build_signature_string(parts: list[tuple[str, Any]]) -> str:
|
||||
"""Собирает каноническую строку для подписи: имя=значение (без разделителя)."""
|
||||
chunks: list[str] = []
|
||||
for key, value in parts:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, bool):
|
||||
chunks.append(f'{key}={"true" if value else "false"}')
|
||||
else:
|
||||
value_str = str(value)
|
||||
if value_str == '':
|
||||
continue
|
||||
chunks.append(f'{key}={value_str}')
|
||||
return ''.join(chunks)
|
||||
|
||||
def _hmac_hex(self, message: str) -> str:
|
||||
"""HMAC-SHA256 в hex."""
|
||||
return hmac.new(
|
||||
self.secret.encode('utf-8'),
|
||||
msg=message.encode('utf-8'),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
def _sign_payin(self, payload: dict[str, Any]) -> str:
|
||||
amount = payload['amount']
|
||||
customer = payload['customer']
|
||||
parts: list[tuple[str, Any]] = [
|
||||
('token', payload['token']),
|
||||
('order_id', payload['order_id']),
|
||||
('amount.value', amount['value']),
|
||||
('amount.currency', amount['currency']),
|
||||
('customer.id', customer['id']),
|
||||
('redirect', payload['redirect']),
|
||||
]
|
||||
return self._hmac_hex(self._build_signature_string(parts))
|
||||
|
||||
def _sign_status(self, payload: dict[str, Any]) -> str:
|
||||
parts: list[tuple[str, Any]] = [
|
||||
('token', payload['token']),
|
||||
('transaction_id', payload['transaction_id']),
|
||||
]
|
||||
return self._hmac_hex(self._build_signature_string(parts))
|
||||
|
||||
def _sign_balance(self, payload: dict[str, Any]) -> str:
|
||||
parts: list[tuple[str, Any]] = [
|
||||
('token', payload['token']),
|
||||
]
|
||||
return self._hmac_hex(self._build_signature_string(parts))
|
||||
|
||||
@staticmethod
|
||||
def _format_amount(amount_rubles: float) -> str:
|
||||
"""Сумма строго '0.00' с точкой (требование Donut P2P)."""
|
||||
return f'{float(amount_rubles):.2f}'
|
||||
|
||||
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
url = f'{self.base_url}/{path.lstrip("/")}'
|
||||
body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
url,
|
||||
data=body,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
return data if isinstance(data, dict) else {'_raw': data}
|
||||
except aiohttp.ClientError as error:
|
||||
logger.exception('Donut API connection error', url=url, error=error)
|
||||
raise
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
amount_rubles: float,
|
||||
order_id: str,
|
||||
customer_id: str,
|
||||
method_description: str,
|
||||
customer_email: str | None = None,
|
||||
customer_phone: str | None = None,
|
||||
callback_url: str | None = None,
|
||||
return_url: str | None = None,
|
||||
receipt: str | None = None,
|
||||
redirect: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Создаёт платёж (PayIn) через Donut P2P.
|
||||
|
||||
POST /p2p_payin
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'token': self.token,
|
||||
'order_id': order_id,
|
||||
'amount': {
|
||||
'value': self._format_amount(amount_rubles),
|
||||
'currency': (settings.DONUT_CURRENCY or 'RUB').upper(),
|
||||
},
|
||||
'customer': {'id': str(customer_id)},
|
||||
'redirect': 'true' if redirect else 'false',
|
||||
'description': method_description,
|
||||
}
|
||||
|
||||
if customer_email:
|
||||
payload['customer']['email'] = customer_email
|
||||
if customer_phone:
|
||||
payload['customer']['phone'] = customer_phone
|
||||
|
||||
if self.method_id:
|
||||
payload['method_id'] = self.method_id
|
||||
if callback_url:
|
||||
payload['callback_url'] = callback_url
|
||||
if return_url:
|
||||
payload['return_url'] = return_url
|
||||
if receipt:
|
||||
payload['receipt'] = receipt[:255]
|
||||
|
||||
payload['signature'] = self._sign_payin(payload)
|
||||
|
||||
logger.info(
|
||||
'Donut API create_payment',
|
||||
order_id=order_id,
|
||||
amount_rubles=amount_rubles,
|
||||
description=method_description,
|
||||
)
|
||||
|
||||
data = await self._post('/p2p_payin', payload)
|
||||
status_obj = (data.get('status') or {}) if isinstance(data, dict) else {}
|
||||
status_type = status_obj.get('type')
|
||||
|
||||
if status_type in ('processing', 'success', 'created'):
|
||||
logger.info(
|
||||
'Donut API payment created',
|
||||
order_id=order_id,
|
||||
transaction_id=data.get('transaction_id'),
|
||||
status_type=status_type,
|
||||
)
|
||||
return data
|
||||
|
||||
error_code = status_obj.get('error_code') or '0'
|
||||
error_msg = status_obj.get('error_description') or status_obj.get('message') or 'Unknown error'
|
||||
logger.error(
|
||||
'Donut create_payment error',
|
||||
error_code=error_code,
|
||||
error_msg=error_msg,
|
||||
response_data=data,
|
||||
)
|
||||
raise DonutAPIError(200, error_msg, error_code)
|
||||
|
||||
async def check_payment(self, *, transaction_id: str) -> dict[str, Any]:
|
||||
"""Получает статус платежа.
|
||||
|
||||
POST /p2p_status
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'token': self.token,
|
||||
'transaction_id': str(transaction_id),
|
||||
}
|
||||
payload['signature'] = self._sign_status(payload)
|
||||
|
||||
logger.info('Donut check_payment', transaction_id=transaction_id)
|
||||
return await self._post('/p2p_status', payload)
|
||||
|
||||
async def get_balance(self) -> dict[str, Any]:
|
||||
"""Получает баланс продавца.
|
||||
|
||||
POST /p2p_balance
|
||||
"""
|
||||
payload: dict[str, Any] = {'token': self.token}
|
||||
payload['signature'] = self._sign_balance(payload)
|
||||
return await self._post('/p2p_balance', payload)
|
||||
|
||||
def verify_callback_signature(self, payload: dict[str, Any]) -> bool:
|
||||
"""Верификация подписи callback (HMAC-SHA256, hex)."""
|
||||
try:
|
||||
received = (payload.get('signature') or '').strip()
|
||||
if not received:
|
||||
logger.warning('Donut callback: отсутствует signature')
|
||||
return False
|
||||
|
||||
amount = payload.get('amount') or {}
|
||||
status_obj = payload.get('status') or {}
|
||||
parts: list[tuple[str, Any]] = [
|
||||
('token', payload.get('token')),
|
||||
('transaction_id', payload.get('transaction_id')),
|
||||
('order_id', payload.get('order_id')),
|
||||
('amount.value', amount.get('value')),
|
||||
('amount.currency', amount.get('currency')),
|
||||
('recalculated', payload.get('recalculated')),
|
||||
('status.type', status_obj.get('type')),
|
||||
]
|
||||
expected = self._hmac_hex(self._build_signature_string(parts))
|
||||
if not hmac.compare_digest(expected.lower(), received.lower()):
|
||||
logger.warning(
|
||||
'Donut callback: invalid signature',
|
||||
expected_prefix=expected[:8],
|
||||
received_prefix=received[:8],
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error('Donut callback verify error', error=error)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
donut_service = DonutService()
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Сервис для работы с Etoplatezhi (paymentpage.etoplatezhi.ru)."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
PAYMENT_PAGE_BASE_URL = 'https://paymentpage.etoplatezhi.ru/payment'
|
||||
|
||||
|
||||
class EtoplatezhiService:
|
||||
"""Сервис для построения URL платежей и верификации callback-ов Etoplatezhi."""
|
||||
|
||||
@property
|
||||
def project_id(self) -> int:
|
||||
return settings.ETOPLATEZHI_PROJECT_ID or 0
|
||||
|
||||
@property
|
||||
def secret_key(self) -> str:
|
||||
return settings.ETOPLATEZHI_SECRET_KEY or ''
|
||||
|
||||
def _flatten_params(
|
||||
self,
|
||||
params: dict[str, Any],
|
||||
prefix: str = '',
|
||||
ignore: set[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Рекурсивно «сплющивает» вложенные словари в список 'key:value' строк.
|
||||
|
||||
Keys разделяются двоеточием. ``frame_mode`` и ``signature`` игнорируются.
|
||||
Booleans приводятся к '1'/'0'.
|
||||
Empty arrays (lists) are excluded entirely per Etoplatezhi spec.
|
||||
"""
|
||||
if ignore is None:
|
||||
ignore = {'frame_mode', 'signature'}
|
||||
|
||||
entries: list[str] = []
|
||||
for key, value in params.items():
|
||||
full_key = f'{prefix}:{key}' if prefix else key
|
||||
if full_key in ignore or key in ignore:
|
||||
continue
|
||||
|
||||
if isinstance(value, dict):
|
||||
entries.extend(self._flatten_params(value, prefix=full_key, ignore=ignore))
|
||||
elif isinstance(value, list):
|
||||
# Empty arrays are excluded entirely per spec
|
||||
if not value:
|
||||
continue
|
||||
# Non-empty arrays: flatten each element with index as key
|
||||
for idx, item in enumerate(value):
|
||||
item_key = f'{full_key}:{idx}'
|
||||
if isinstance(item, dict):
|
||||
entries.extend(self._flatten_params(item, prefix=item_key, ignore=ignore))
|
||||
elif isinstance(item, bool):
|
||||
entries.append(f'{item_key}:{"1" if item else "0"}')
|
||||
elif item is not None:
|
||||
entries.append(f'{item_key}:{item}')
|
||||
elif isinstance(value, bool):
|
||||
entries.append(f'{full_key}:{"1" if value else "0"}')
|
||||
elif value is not None:
|
||||
entries.append(f'{full_key}:{value}')
|
||||
|
||||
return entries
|
||||
|
||||
def _sign(self, params: dict[str, Any]) -> str:
|
||||
"""HMAC-SHA512 + base64 подпись параметров.
|
||||
|
||||
Algorithm:
|
||||
1. Flatten nested dicts with ':' separator.
|
||||
2. Each leaf → "key:value".
|
||||
3. Sort alphabetically by full key string.
|
||||
4. Join with ';'.
|
||||
5. HMAC-SHA512 with secret_key.
|
||||
6. base64-encode the raw digest.
|
||||
"""
|
||||
entries = self._flatten_params(params)
|
||||
entries.sort()
|
||||
message = ';'.join(entries)
|
||||
|
||||
digest = hmac.new(
|
||||
self.secret_key.encode('utf-8'),
|
||||
message.encode('utf-8'),
|
||||
hashlib.sha512,
|
||||
).digest()
|
||||
|
||||
return base64.b64encode(digest).decode('utf-8')
|
||||
|
||||
def build_payment_url(
|
||||
self,
|
||||
*,
|
||||
project_id: int,
|
||||
payment_id: str,
|
||||
payment_amount: int,
|
||||
payment_currency: str = 'RUB',
|
||||
customer_id: str,
|
||||
description: str | None = None,
|
||||
callback_url: str | None = None,
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
force_payment_method: str | None = None,
|
||||
customer_email: str | None = None,
|
||||
language_code: str | None = None,
|
||||
) -> str:
|
||||
"""Строит URL для редиректа на платёжную страницу Etoplatezhi.
|
||||
|
||||
Args:
|
||||
project_id: ID проекта в Etoplatezhi.
|
||||
payment_id: Наш internal order_id.
|
||||
payment_amount: Сумма в минорных единицах (копейках).
|
||||
payment_currency: ISO 4217 код валюты.
|
||||
customer_id: Telegram ID или guest-идентификатор покупателя.
|
||||
description: Описание платежа.
|
||||
callback_url: URL для callback (POST JSON).
|
||||
success_url: URL редиректа при успехе.
|
||||
fail_url: URL редиректа при ошибке.
|
||||
force_payment_method: 'sbp' или 'card' для принудительного выбора.
|
||||
customer_email: Email покупателя.
|
||||
language_code: Язык интерфейса ('ru', 'en').
|
||||
|
||||
Returns:
|
||||
Полный URL с параметрами и подписью.
|
||||
"""
|
||||
params: dict[str, Any] = {
|
||||
'project_id': project_id,
|
||||
'payment_id': payment_id,
|
||||
'payment_amount': payment_amount,
|
||||
'payment_currency': payment_currency,
|
||||
'customer_id': customer_id,
|
||||
}
|
||||
|
||||
if description:
|
||||
params['payment_description'] = description
|
||||
if callback_url:
|
||||
params['merchant_callback_url'] = callback_url
|
||||
if success_url:
|
||||
params['redirect_success_url'] = success_url
|
||||
if fail_url:
|
||||
params['redirect_fail_url'] = fail_url
|
||||
if force_payment_method:
|
||||
params['force_payment_method'] = force_payment_method
|
||||
if customer_email:
|
||||
params['customer_email'] = customer_email
|
||||
if language_code:
|
||||
params['language_code'] = language_code
|
||||
|
||||
params['signature'] = self._sign(params)
|
||||
|
||||
logger.info(
|
||||
'Etoplatezhi: building payment URL',
|
||||
payment_id=payment_id,
|
||||
payment_amount=payment_amount,
|
||||
customer_id=customer_id,
|
||||
)
|
||||
|
||||
return f'{PAYMENT_PAGE_BASE_URL}?{urlencode(params)}'
|
||||
|
||||
def verify_callback_signature(self, payload: dict[str, Any]) -> bool:
|
||||
"""Верифицирует подпись в callback-е Etoplatezhi.
|
||||
|
||||
Подпись находится внутри JSON body (поле ``signature``).
|
||||
Для проверки: удаляем ``signature`` из всех уровней вложенности,
|
||||
вычисляем подпись по оставшимся данным и сравниваем.
|
||||
"""
|
||||
try:
|
||||
received_signature = payload.get('signature')
|
||||
if not received_signature:
|
||||
logger.warning('Etoplatezhi callback: отсутствует signature в payload')
|
||||
return False
|
||||
|
||||
# Deep-copy payload and strip all 'signature' keys recursively
|
||||
cleaned = self._strip_signature_keys(payload)
|
||||
|
||||
expected = self._sign(cleaned)
|
||||
return hmac.compare_digest(expected, str(received_signature))
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Etoplatezhi callback verify error', error=e)
|
||||
return False
|
||||
|
||||
def _strip_signature_keys(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Рекурсивно удаляет ключ ``signature`` из словаря и вложенных словарей."""
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if key == 'signature':
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
result[key] = self._strip_signature_keys(value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
# Singleton instance
|
||||
etoplatezhi_service = EtoplatezhiService()
|
||||
@@ -1,146 +0,0 @@
|
||||
"""Утилиты для синхронизации токена внешней админки."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import SystemSetting
|
||||
from app.services.system_settings_service import (
|
||||
ReadOnlySettingError,
|
||||
bot_configuration_service,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def ensure_external_admin_token(
|
||||
bot_username: str | None,
|
||||
bot_id: int | None,
|
||||
) -> str | None:
|
||||
"""Генерирует и сохраняет токен внешней админки, если требуется."""
|
||||
|
||||
username_raw = (bot_username or '').strip()
|
||||
if not username_raw:
|
||||
logger.warning(
|
||||
'⚠️ Не удалось обеспечить токен внешней админки: username бота отсутствует',
|
||||
)
|
||||
return None
|
||||
|
||||
normalized_username = username_raw.lstrip('@').lower()
|
||||
if not normalized_username:
|
||||
logger.warning(
|
||||
'⚠️ Не удалось обеспечить токен внешней админки: username пустой после нормализации',
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
token = settings.build_external_admin_token(normalized_username)
|
||||
except Exception as error: # pragma: no cover - защитный блок
|
||||
logger.error('❌ Ошибка генерации токена внешней админки', error=error)
|
||||
return None
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SystemSetting.key, SystemSetting.value).where(
|
||||
SystemSetting.key.in_(['EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'])
|
||||
)
|
||||
)
|
||||
rows = dict(result.all())
|
||||
existing_token = rows.get('EXTERNAL_ADMIN_TOKEN')
|
||||
existing_bot_id_raw = rows.get('EXTERNAL_ADMIN_TOKEN_BOT_ID')
|
||||
|
||||
existing_bot_id: int | None = None
|
||||
if existing_bot_id_raw is not None:
|
||||
try:
|
||||
existing_bot_id = int(existing_bot_id_raw)
|
||||
except (TypeError, ValueError): # pragma: no cover - защита от мусорных значений
|
||||
logger.warning(
|
||||
'⚠️ Не удалось разобрать сохраненный идентификатор бота внешней админки',
|
||||
existing_bot_id_raw=existing_bot_id_raw,
|
||||
)
|
||||
|
||||
if existing_token == token and existing_bot_id == bot_id:
|
||||
if settings.get_external_admin_token() != token:
|
||||
settings.EXTERNAL_ADMIN_TOKEN = token
|
||||
if existing_bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID:
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
|
||||
return token
|
||||
|
||||
if existing_bot_id is not None and bot_id is not None and existing_bot_id != bot_id:
|
||||
logger.error(
|
||||
'❌ Обнаружено несовпадение ID бота для токена внешней админки: сохранен , текущий',
|
||||
existing_bot_id=existing_bot_id,
|
||||
bot_id=bot_id,
|
||||
)
|
||||
|
||||
try:
|
||||
await bot_configuration_service.reset_value(
|
||||
session,
|
||||
'EXTERNAL_ADMIN_TOKEN',
|
||||
force=True,
|
||||
)
|
||||
await bot_configuration_service.reset_value(
|
||||
session,
|
||||
'EXTERNAL_ADMIN_TOKEN_BOT_ID',
|
||||
force=True,
|
||||
)
|
||||
await session.commit()
|
||||
logger.warning(
|
||||
'⚠️ Токен внешней админки очищен из-за несовпадения идентификаторов бота',
|
||||
)
|
||||
except Exception as cleanup_error: # pragma: no cover - защитный блок
|
||||
await session.rollback()
|
||||
logger.error(
|
||||
'❌ Не удалось очистить токен внешней админки после обнаружения подмены',
|
||||
cleanup_error=cleanup_error,
|
||||
)
|
||||
finally:
|
||||
settings.EXTERNAL_ADMIN_TOKEN = None
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = None
|
||||
|
||||
return None
|
||||
|
||||
updates: list[tuple[str, object]] = []
|
||||
if existing_token != token:
|
||||
updates.append(('EXTERNAL_ADMIN_TOKEN', token))
|
||||
|
||||
if bot_id is not None and existing_bot_id != bot_id:
|
||||
updates.append(('EXTERNAL_ADMIN_TOKEN_BOT_ID', bot_id))
|
||||
|
||||
if not updates:
|
||||
# Токен совпал, но могли отсутствовать значения в настройках приложения
|
||||
if settings.get_external_admin_token() != (existing_token or token):
|
||||
settings.EXTERNAL_ADMIN_TOKEN = existing_token or token
|
||||
if existing_bot_id is not None and (existing_bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID):
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
|
||||
elif bot_id is not None and bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID and existing_bot_id is None:
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = bot_id
|
||||
return existing_token or token
|
||||
|
||||
try:
|
||||
for key, value in updates:
|
||||
await bot_configuration_service.set_value(
|
||||
session,
|
||||
key,
|
||||
value,
|
||||
force=True,
|
||||
)
|
||||
await session.commit()
|
||||
logger.info('✅ Токен внешней админки синхронизирован для @', normalized_username=normalized_username)
|
||||
except ReadOnlySettingError: # pragma: no cover - force=True предотвращает исключение
|
||||
await session.rollback()
|
||||
logger.warning(
|
||||
'⚠️ Не удалось сохранить токен внешней админки из-за ограничения доступа',
|
||||
)
|
||||
return None
|
||||
|
||||
return token
|
||||
except SQLAlchemyError as error:
|
||||
logger.error('❌ Ошибка сохранения токена внешней админки', error=error)
|
||||
return None
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Сервис для работы с API Jupiter (FPGate P2P v2.1, app.juppiter.tech)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class JupiterAPIError(Exception):
|
||||
"""Ошибка API Jupiter."""
|
||||
|
||||
def __init__(self, status_code: int, message: str, code: str | None = None) -> None:
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.api_code = code
|
||||
super().__init__(f'Jupiter API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class JupiterService:
|
||||
"""Клиент для FPGate P2P v2.1 (Jupiter / app.juppiter.tech)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return (settings.JUPITER_BASE_URL or 'https://app.juppiter.tech').rstrip('/')
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
return settings.JUPITER_TOKEN or ''
|
||||
|
||||
@property
|
||||
def secret(self) -> str:
|
||||
return settings.JUPITER_SECRET or ''
|
||||
|
||||
@property
|
||||
def method_id(self) -> str | None:
|
||||
value = (settings.JUPITER_METHOD_ID or '').strip()
|
||||
return value or None
|
||||
|
||||
@property
|
||||
def method_description(self) -> str:
|
||||
return (settings.JUPITER_METHOD_DESCRIPTION or 'SBP').strip() or 'SBP'
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
@staticmethod
|
||||
def _build_signature_string(parts: list[tuple[str, Any]]) -> str:
|
||||
"""Собирает каноническую строку для подписи: имя=значение... в порядке полей.
|
||||
|
||||
По спецификации FPGate P2P v2.1: «Если поле подписываемое, но не обязательное,
|
||||
то оно входит в подпись, если оно присутствует в запросе и имеет непустое значение».
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
for key, value in parts:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, bool):
|
||||
chunks.append(f'{key}={"true" if value else "false"}')
|
||||
continue
|
||||
value_str = str(value)
|
||||
if value_str == '':
|
||||
continue
|
||||
chunks.append(f'{key}={value_str}')
|
||||
return ''.join(chunks)
|
||||
|
||||
def _hmac_hex(self, message: str) -> str:
|
||||
"""HMAC-SHA256 в hex (регистр не важен по спецификации)."""
|
||||
return hmac.new(
|
||||
self.secret.encode('utf-8'),
|
||||
msg=message.encode('utf-8'),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
def _sign_payin(self, payload: dict[str, Any]) -> str:
|
||||
amount = payload['amount']
|
||||
customer = payload['customer']
|
||||
parts: list[tuple[str, Any]] = [
|
||||
('token', payload['token']),
|
||||
('order_id', payload['order_id']),
|
||||
('amount.value', amount['value']),
|
||||
('amount.currency', amount['currency']),
|
||||
('customer.id', customer['id']),
|
||||
('redirect', payload['redirect']),
|
||||
]
|
||||
return self._hmac_hex(self._build_signature_string(parts))
|
||||
|
||||
def _sign_status(self, payload: dict[str, Any]) -> str:
|
||||
parts: list[tuple[str, Any]] = [
|
||||
('token', payload['token']),
|
||||
('transaction_id', payload['transaction_id']),
|
||||
]
|
||||
return self._hmac_hex(self._build_signature_string(parts))
|
||||
|
||||
def _sign_balance(self, payload: dict[str, Any]) -> str:
|
||||
parts: list[tuple[str, Any]] = [
|
||||
('token', payload['token']),
|
||||
]
|
||||
return self._hmac_hex(self._build_signature_string(parts))
|
||||
|
||||
@staticmethod
|
||||
def _format_amount(amount_rubles: float) -> str:
|
||||
"""Сумма строго '0.00' с точкой-разделителем (требование P2P v2.1)."""
|
||||
return f'{float(amount_rubles):.2f}'
|
||||
|
||||
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
url = f'{self.base_url}/{path.lstrip("/")}'
|
||||
body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
url,
|
||||
data=body,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
return data if isinstance(data, dict) else {'_raw': data}
|
||||
except aiohttp.ClientError as error:
|
||||
logger.exception('Jupiter API connection error', url=url, error=error)
|
||||
raise
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
amount_rubles: float,
|
||||
order_id: str,
|
||||
customer_id: str,
|
||||
customer_email: str | None = None,
|
||||
customer_phone: str | None = None,
|
||||
customer_name: str | None = None,
|
||||
callback_url: str | None = None,
|
||||
receipt: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Создаёт платёж (PayIn) согласно FPGate P2P v2.1.
|
||||
|
||||
POST /p2p_payin_v2.1
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'token': self.token,
|
||||
'order_id': order_id,
|
||||
'amount': {
|
||||
'value': self._format_amount(amount_rubles),
|
||||
'currency': (settings.JUPITER_CURRENCY or 'RUB').upper(),
|
||||
},
|
||||
'customer': {
|
||||
'id': str(customer_id),
|
||||
'email': customer_email or settings.JUPITER_FALLBACK_EMAIL or 'user@vpn.bot',
|
||||
'phone': customer_phone or settings.JUPITER_FALLBACK_PHONE or '0000000000',
|
||||
'name': customer_name or settings.JUPITER_FALLBACK_NAME or 'User',
|
||||
},
|
||||
'redirect': 'false',
|
||||
'description': (description or self.method_description)[:255],
|
||||
}
|
||||
|
||||
if self.method_id:
|
||||
payload['method_id'] = self.method_id
|
||||
if callback_url:
|
||||
payload['callback_url'] = callback_url
|
||||
if receipt:
|
||||
payload['receipt'] = receipt[:255]
|
||||
|
||||
payload['signature'] = self._sign_payin(payload)
|
||||
|
||||
logger.info('Jupiter API create_payment', order_id=order_id, amount_rubles=amount_rubles)
|
||||
|
||||
data = await self._post('/p2p_payin_v2.1', payload)
|
||||
status = (data.get('status') or {}) if isinstance(data, dict) else {}
|
||||
status_type = status.get('type')
|
||||
|
||||
if status_type in ('processing', 'success'):
|
||||
logger.info(
|
||||
'Jupiter API payment created',
|
||||
order_id=order_id,
|
||||
transaction_id=data.get('transaction_id'),
|
||||
status_type=status_type,
|
||||
)
|
||||
return data
|
||||
|
||||
error_code = status.get('error_code') or '0'
|
||||
error_msg = status.get('error_description') or 'Unknown error'
|
||||
logger.error(
|
||||
'Jupiter create_payment error',
|
||||
error_code=error_code,
|
||||
error_msg=error_msg,
|
||||
response_data=data,
|
||||
)
|
||||
raise JupiterAPIError(200, error_msg, error_code)
|
||||
|
||||
async def check_payment(self, *, transaction_id: str) -> dict[str, Any]:
|
||||
"""Получает статус платежа.
|
||||
|
||||
POST /p2p_status_v2.1
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'token': self.token,
|
||||
'transaction_id': str(transaction_id),
|
||||
}
|
||||
payload['signature'] = self._sign_status(payload)
|
||||
|
||||
logger.info('Jupiter check_payment', transaction_id=transaction_id)
|
||||
data = await self._post('/p2p_status_v2.1', payload)
|
||||
return data
|
||||
|
||||
async def get_balance(self) -> dict[str, Any]:
|
||||
"""Получает баланс продавца.
|
||||
|
||||
POST /p2p_balance_v2.1
|
||||
"""
|
||||
payload: dict[str, Any] = {'token': self.token}
|
||||
payload['signature'] = self._sign_balance(payload)
|
||||
data = await self._post('/p2p_balance_v2.1', payload)
|
||||
return data
|
||||
|
||||
def verify_callback_signature(self, payload: dict[str, Any]) -> bool:
|
||||
"""Верификация подписи callback (HMAC-SHA256, hex)."""
|
||||
try:
|
||||
received = (payload.get('signature') or '').strip()
|
||||
if not received:
|
||||
logger.warning('Jupiter callback: отсутствует signature')
|
||||
return False
|
||||
|
||||
amount = payload.get('amount') or {}
|
||||
status = payload.get('status') or {}
|
||||
parts: list[tuple[str, Any]] = [
|
||||
('token', payload.get('token')),
|
||||
('transaction_id', payload.get('transaction_id')),
|
||||
('order_id', payload.get('order_id')),
|
||||
('amount.value', amount.get('value')),
|
||||
('amount.currency', amount.get('currency')),
|
||||
('recalculated', payload.get('recalculated')),
|
||||
('status.type', status.get('type')),
|
||||
]
|
||||
expected = self._hmac_hex(self._build_signature_string(parts))
|
||||
if not hmac.compare_digest(expected.lower(), received.lower()):
|
||||
logger.warning(
|
||||
'Jupiter callback: invalid signature',
|
||||
expected_prefix=expected[:8],
|
||||
received_prefix=received[:8],
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error('Jupiter callback verify error', error=error)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
jupiter_service = JupiterService()
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Сервис для работы с API Lava Business (gate.lava.ru)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class LavaAPIError(Exception):
|
||||
"""Ошибка API Lava."""
|
||||
|
||||
def __init__(self, status_code: int, message: str, code: str | int | None = None) -> None:
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.api_code = code
|
||||
super().__init__(f'Lava API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class LavaService:
|
||||
"""Клиент для Lava Business API (gate.lava.ru).
|
||||
|
||||
Подпись запросов: HMAC-SHA256(json_body, secret_key) → hex.
|
||||
Передаётся в заголовке ``Signature``.
|
||||
Ключи `secret_key` (запросы) и `secret_key_2` (webhook) выдаются мерчанту в личном кабинете.
|
||||
Каноническая строка для подписи — JSON в том же порядке, в котором отправляется в теле.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return (settings.LAVA_BASE_URL or 'https://gate.lava.ru').rstrip('/')
|
||||
|
||||
@property
|
||||
def shop_id(self) -> str:
|
||||
return settings.LAVA_SHOP_ID or ''
|
||||
|
||||
@property
|
||||
def secret_key(self) -> str:
|
||||
return settings.LAVA_SECRET_KEY or ''
|
||||
|
||||
@property
|
||||
def webhook_secret(self) -> str:
|
||||
# secret_key_2 — для проверки подписи webhook'а
|
||||
return settings.LAVA_WEBHOOK_SECRET or ''
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
@staticmethod
|
||||
def _serialize(payload: dict[str, Any]) -> str:
|
||||
"""Сериализация JSON для подписи и тела запроса.
|
||||
|
||||
Lava подписывает байт-в-байт ту же строку, что и отправляется в теле, поэтому
|
||||
порядок ключей определяется порядком вставки в payload (Python ≥3.7 dict сохраняет
|
||||
порядок). Используем компактный сепаратор и UTF-8 без экранирования юникода.
|
||||
"""
|
||||
return json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
|
||||
|
||||
def _hmac_hex(self, message: str | bytes, key: str | None = None) -> str:
|
||||
secret = (key if key is not None else self.secret_key) or ''
|
||||
msg_bytes = message if isinstance(message, (bytes, bytearray)) else message.encode('utf-8')
|
||||
return hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
msg=msg_bytes,
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
def _build_headers(self, body: str) -> dict[str, str]:
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Signature': self._hmac_hex(body),
|
||||
}
|
||||
|
||||
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
url = f'{self.base_url}/{path.lstrip("/")}'
|
||||
body = self._serialize(payload)
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(url, data=body, headers=self._build_headers(body)) as response:
|
||||
try:
|
||||
data = await response.json(content_type=None)
|
||||
except Exception:
|
||||
text = await response.text()
|
||||
data = {'_raw': text}
|
||||
if not isinstance(data, dict):
|
||||
data = {'_raw': data}
|
||||
if response.status >= 400:
|
||||
error_msg = (
|
||||
data.get('error')
|
||||
or (data.get('data') or {}).get('error')
|
||||
or data.get('message')
|
||||
or 'Lava API HTTP error'
|
||||
)
|
||||
logger.warning(
|
||||
'Lava API HTTP error',
|
||||
url=url,
|
||||
status=response.status,
|
||||
error_msg=str(error_msg),
|
||||
code=data.get('code'),
|
||||
)
|
||||
raise LavaAPIError(response.status, str(error_msg), data.get('code'))
|
||||
return data
|
||||
except aiohttp.ClientError as error:
|
||||
logger.exception('Lava API connection error', url=url, error=error)
|
||||
raise
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
*,
|
||||
amount_rubles: float,
|
||||
order_id: str,
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
hook_url: str | None = None,
|
||||
expire_minutes: int | None = None,
|
||||
comment: str | None = None,
|
||||
custom_fields: str | None = None,
|
||||
include_service: list[str] | None = None,
|
||||
exclude_service: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Создаёт инвойс через POST /api/v2/invoice/create.
|
||||
|
||||
Сумма передаётся в рублях с двумя знаками после запятой.
|
||||
``orderId`` — наш уникальный идентификатор платежа.
|
||||
"""
|
||||
# Порядок полей важен (этим же порядком сериализуется и подписывается)
|
||||
payload: dict[str, Any] = {
|
||||
'sum': round(float(amount_rubles), 2),
|
||||
'orderId': str(order_id),
|
||||
'shopId': self.shop_id,
|
||||
}
|
||||
if hook_url:
|
||||
payload['hookUrl'] = hook_url[:500]
|
||||
if success_url:
|
||||
payload['successUrl'] = success_url[:500]
|
||||
if fail_url:
|
||||
payload['failUrl'] = fail_url[:500]
|
||||
if expire_minutes is not None:
|
||||
# Lava лимит: 1..7200 минут (5 дней)
|
||||
payload['expire'] = max(1, min(7200, int(expire_minutes)))
|
||||
if comment:
|
||||
payload['comment'] = comment[:255]
|
||||
if custom_fields:
|
||||
payload['customFields'] = custom_fields[:500]
|
||||
if include_service:
|
||||
payload['includeService'] = list(include_service)
|
||||
if exclude_service:
|
||||
payload['excludeService'] = list(exclude_service)
|
||||
|
||||
logger.info('Lava API invoice/create', order_id=order_id, sum=payload['sum'])
|
||||
data = await self._post('/api/v2/invoice/create', payload)
|
||||
|
||||
# Lava возвращает {"status": "success", "data": {...}} или {"status": "error", "error": "..."}
|
||||
if isinstance(data.get('status'), str) and data['status'].lower() == 'error':
|
||||
raise LavaAPIError(200, str(data.get('error') or data.get('message') or 'unknown'))
|
||||
|
||||
return data
|
||||
|
||||
async def get_invoice_status(
|
||||
self,
|
||||
*,
|
||||
order_id: str | None = None,
|
||||
invoice_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""POST /api/v2/invoice/status — статус инвойса по orderId или invoiceId."""
|
||||
if not order_id and not invoice_id:
|
||||
raise ValueError('Lava status: order_id or invoice_id required')
|
||||
|
||||
payload: dict[str, Any] = {'shopId': self.shop_id}
|
||||
if invoice_id:
|
||||
payload['invoiceId'] = str(invoice_id)
|
||||
if order_id:
|
||||
payload['orderId'] = str(order_id)
|
||||
|
||||
logger.info('Lava API invoice/status', order_id=order_id, invoice_id=invoice_id)
|
||||
return await self._post('/api/v2/invoice/status', payload)
|
||||
|
||||
async def get_services(self) -> dict[str, Any]:
|
||||
"""POST /api/v2/invoice/services — доступные методы оплаты для shopId."""
|
||||
payload: dict[str, Any] = {'shopId': self.shop_id}
|
||||
return await self._post('/api/v2/invoice/services', payload)
|
||||
|
||||
def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool:
|
||||
"""Верификация подписи webhook (заголовок ``Authorization``).
|
||||
|
||||
Lava Business webhook подписан HMAC-SHA256 от raw JSON body ключом ``secret_key_2``.
|
||||
"""
|
||||
try:
|
||||
if not received_signature:
|
||||
logger.warning('Lava webhook: отсутствует Authorization header')
|
||||
return False
|
||||
if not self.webhook_secret:
|
||||
logger.error('Lava webhook: LAVA_WEBHOOK_SECRET не настроен')
|
||||
return False
|
||||
|
||||
# HMAC берётся напрямую от raw bytes — без decode/encode round-trip,
|
||||
# чтобы не терять байты при некорректной кодировке payload.
|
||||
expected = self._hmac_hex(raw_body, key=self.webhook_secret)
|
||||
received = received_signature.strip()
|
||||
|
||||
if not hmac.compare_digest(expected.lower(), received.lower()):
|
||||
logger.warning(
|
||||
'Lava webhook: invalid signature',
|
||||
received_prefix=received[:8],
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error('Lava webhook verify error', error=error)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
lava_service = LavaService()
|
||||
@@ -373,7 +373,22 @@ class MonitoringService:
|
||||
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if user and self.bot:
|
||||
await self._send_subscription_expired_notification(user, subscription, tariff_name=_tariff_name)
|
||||
# Skip notification if user has another ACTIVE subscription (multi-tariff)
|
||||
skip_notify = False
|
||||
if settings.is_multi_tariff_enabled():
|
||||
other_active = await db.execute(
|
||||
select(Subscription.id)
|
||||
.where(
|
||||
Subscription.user_id == user.id,
|
||||
Subscription.id != subscription.id,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > datetime.now(UTC),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
skip_notify = other_active.scalar_one_or_none() is not None
|
||||
if not skip_notify:
|
||||
await self._send_subscription_expired_notification(user, subscription, tariff_name=_tariff_name)
|
||||
|
||||
logger.info(
|
||||
"🔴 Подписка пользователя истекла и статус изменен на 'expired'", user_id=subscription.user_id
|
||||
@@ -965,8 +980,12 @@ class MonitoringService:
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Lookback window — don't re-check subscriptions expired more than 30 days ago
|
||||
lookback = now - timedelta(days=30)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.tariff),
|
||||
@@ -974,7 +993,10 @@ class MonitoringService:
|
||||
.where(
|
||||
and_(
|
||||
Subscription.is_trial == False,
|
||||
Subscription.status == SubscriptionStatus.EXPIRED.value,
|
||||
Subscription.end_date <= now,
|
||||
Subscription.end_date >= lookback,
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -998,6 +1020,21 @@ class MonitoringService:
|
||||
if subscription.end_date is None:
|
||||
continue
|
||||
|
||||
# Skip if user has another ACTIVE subscription — they still have service
|
||||
if settings.is_multi_tariff_enabled():
|
||||
other_active = await db.execute(
|
||||
select(Subscription.id)
|
||||
.where(
|
||||
Subscription.user_id == user.id,
|
||||
Subscription.id != subscription.id,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if other_active.scalar_one_or_none() is not None:
|
||||
continue
|
||||
|
||||
time_since_end = now - subscription.end_date
|
||||
if time_since_end.total_seconds() < 0:
|
||||
continue
|
||||
@@ -1007,7 +1044,7 @@ class MonitoringService:
|
||||
# Day 1 reminder
|
||||
if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2:
|
||||
if not await notification_sent(db, user.id, subscription.id, 'expired_1d'):
|
||||
success = await self._send_expired_day1_notification(user, subscription)
|
||||
success = await self._send_expired_day1_notification(db, user, subscription)
|
||||
if success:
|
||||
await record_notification(db, user.id, subscription.id, 'expired_1d')
|
||||
sent_day1 += 1
|
||||
@@ -1090,6 +1127,7 @@ class MonitoringService:
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.tariff),
|
||||
@@ -1100,6 +1138,7 @@ class MonitoringService:
|
||||
Subscription.is_trial == False,
|
||||
Subscription.end_date > current_time,
|
||||
Subscription.end_date <= threshold_date,
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1772,12 +1811,29 @@ class MonitoringService:
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool:
|
||||
async def _send_expired_day1_notification(self, db: AsyncSession, user: User, subscription: Subscription) -> bool:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
tariff = getattr(subscription, 'tariff', None)
|
||||
tariff_label = ''
|
||||
if settings.is_multi_tariff_enabled() and hasattr(subscription, 'tariff') and subscription.tariff:
|
||||
tariff_label = f' «{subscription.tariff.name}»'
|
||||
if settings.is_multi_tariff_enabled() and tariff:
|
||||
tariff_label = f' «{tariff.name}»'
|
||||
|
||||
renewal_period = (tariff.get_shortest_period() if tariff else None) or 30
|
||||
try:
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
pricing = await pricing_engine.calculate_renewal_price(db, subscription, renewal_period, user=user)
|
||||
renewal_price_kopeks = pricing.final_total
|
||||
except Exception as price_error:
|
||||
logger.warning(
|
||||
'Не удалось рассчитать цену продления для уведомления expired_1d, используем PRICE_30_DAYS',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
error=str(price_error),
|
||||
)
|
||||
renewal_price_kopeks = settings.PRICE_30_DAYS
|
||||
|
||||
template = texts.get(
|
||||
'SUBSCRIPTION_EXPIRED_1D',
|
||||
(
|
||||
@@ -1787,7 +1843,7 @@ class MonitoringService:
|
||||
)
|
||||
message = template.format(
|
||||
end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'),
|
||||
price=settings.format_price(settings.PRICE_30_DAYS),
|
||||
price=settings.format_price(renewal_price_kopeks),
|
||||
tariff_label=tariff_label,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
"""Mixin для интеграции с Antilopay (lk.antilopay.com)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.antilopay_service import antilopay_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов Antilopay -> internal
|
||||
ANTILOPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'PENDING': ('pending', False),
|
||||
'SUCCESS': ('success', True),
|
||||
'FAIL': ('failed', False),
|
||||
'CANCEL': ('cancelled', False),
|
||||
'EXPIRED': ('expired', False),
|
||||
'CHARGEBACK': ('chargeback', False),
|
||||
'REVERSED': ('reversed', False),
|
||||
}
|
||||
|
||||
|
||||
class AntilopayPaymentMixin:
|
||||
"""Mixin для работы с платежами Antilopay."""
|
||||
|
||||
async def create_antilopay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
payment_method_type: str | None = None,
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж Antilopay.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_antilopay_enabled():
|
||||
logger.error('Antilopay не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.ANTILOPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Antilopay: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
ANTILOPAY_MIN_AMOUNT_KOPEKS=settings.ANTILOPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.ANTILOPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Antilopay: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
ANTILOPAY_MAX_AMOUNT_KOPEKS=settings.ANTILOPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'alp{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.ANTILOPAY_CURRENCY
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
try:
|
||||
# Определяем prefer_methods по типу подметода
|
||||
prefer_methods: list[str] | None = None
|
||||
if payment_method_type == 'sbp':
|
||||
prefer_methods = ['SBP']
|
||||
elif payment_method_type == 'card':
|
||||
prefer_methods = ['CARD_RU']
|
||||
elif payment_method_type == 'sberpay':
|
||||
prefer_methods = ['SBER_PAY']
|
||||
|
||||
# Формируем success/fail URL
|
||||
result_url = return_url or settings.ANTILOPAY_RETURN_URL
|
||||
|
||||
# merchant_extra — строка до 255 символов для callback
|
||||
merchant_extra = order_id
|
||||
|
||||
# Создаем платеж через API
|
||||
api_result = await antilopay_service.create_payment(
|
||||
amount_rubles=amount_rubles,
|
||||
order_id=order_id,
|
||||
product_name=settings.ANTILOPAY_PRODUCT_NAME,
|
||||
product_type=settings.ANTILOPAY_PRODUCT_TYPE,
|
||||
description=description,
|
||||
customer_email=email,
|
||||
prefer_methods=prefer_methods,
|
||||
success_url=result_url,
|
||||
fail_url=result_url,
|
||||
merchant_extra=merchant_extra,
|
||||
)
|
||||
|
||||
payment_id = api_result.get('payment_id')
|
||||
payment_url = api_result.get('payment_url')
|
||||
|
||||
logger.info(
|
||||
'Antilopay: получен ответ API',
|
||||
order_id=order_id,
|
||||
payment_id=payment_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
lifetime = settings.ANTILOPAY_PAYMENT_LIFETIME_MINUTES
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
|
||||
|
||||
# Сохраняем в БД
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
local_payment = await antilopay_crud.create_antilopay_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method_type,
|
||||
antilopay_payment_id=payment_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Antilopay: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'payment_id': payment_id,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Antilopay: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
async def process_antilopay_callback(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает callback от Antilopay.
|
||||
|
||||
Подпись проверяется в webserver/payments.py до вызова этого метода.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело callback (signature проверена в webserver)
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
callback_type = payload.get('type')
|
||||
if callback_type != 'payment':
|
||||
logger.info('Antilopay callback: неизвестный тип', callback_type=callback_type)
|
||||
return True # Не наш тип — не ошибка
|
||||
|
||||
antilopay_payment_id = payload.get('payment_id')
|
||||
antilopay_status = payload.get('status')
|
||||
our_order_id = payload.get('order_id')
|
||||
|
||||
if not our_order_id or not antilopay_status:
|
||||
logger.warning('Antilopay callback: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Определяем is_paid по статусу
|
||||
is_confirmed = antilopay_status == 'SUCCESS'
|
||||
|
||||
# Ищем платеж по order_id
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
payment = await antilopay_crud.get_antilopay_payment_by_order_id(db, our_order_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'Antilopay callback: платеж не найден',
|
||||
order_id=our_order_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await antilopay_crud.get_antilopay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Antilopay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Проверка дублирования (re-check from locked row)
|
||||
if payment.is_paid:
|
||||
logger.info('Antilopay callback: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = ANTILOPAY_STATUS_MAP.get(antilopay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
# Если статус SUCCESS, принудительно считаем оплаченным
|
||||
if is_confirmed:
|
||||
is_paid = True
|
||||
internal_status = 'success'
|
||||
|
||||
callback_payload = {
|
||||
'antilopay_payment_id': antilopay_payment_id,
|
||||
'status': antilopay_status,
|
||||
'amount': payload.get('amount'),
|
||||
'original_amount': payload.get('original_amount'),
|
||||
'fee': payload.get('fee'),
|
||||
'currency': payload.get('currency'),
|
||||
'pay_method': payload.get('pay_method'),
|
||||
'pay_data': payload.get('pay_data'),
|
||||
'customer': payload.get('customer'),
|
||||
'merchant_extra': payload.get('merchant_extra'),
|
||||
}
|
||||
|
||||
# Проверка суммы ДО обновления статуса
|
||||
if is_paid:
|
||||
original_amount = payload.get('original_amount')
|
||||
if original_amount is not None:
|
||||
# original_amount в РУБЛЯХ (float), конвертируем в копейки
|
||||
received_kopeks = round(float(original_amount) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'Antilopay amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await antilopay_crud.update_antilopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
# Финализируем платеж если оплачен — без промежуточного commit
|
||||
if is_paid:
|
||||
# Inline field assignments to keep FOR UPDATE lock intact
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.antilopay_payment_id = str(antilopay_payment_id) if antilopay_payment_id else None
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_antilopay_payment(db, payment, trigger='webhook')
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
payment = await antilopay_crud.update_antilopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Antilopay callback: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_antilopay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock must be acquired by the caller before invoking this method.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'Antilopay платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
# Read fresh metadata AFTER lock to avoid stale data
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
# --- Guest purchase flow ---
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=payment.order_id,
|
||||
provider_name='antilopay',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
# Ensure paid fields are set (idempotent — caller may have already set them)
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для Antilopay', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
# Загружаем промогруппы в асинхронном контексте
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = payment.order_id
|
||||
|
||||
# Проверяем дупликат транзакции
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.ANTILOPAY,
|
||||
)
|
||||
|
||||
display_name = settings.get_antilopay_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.ANTILOPAY,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await antilopay_crud.link_antilopay_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('Antilopay платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.ANTILOPAY,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Antilopay', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления Antilopay', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'\u2705 <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю Antilopay', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан Antilopay платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_antilopay_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Проверяет статус платежа через API Antilopay."""
|
||||
try:
|
||||
result = await antilopay_service.check_payment(order_id=order_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error('Antilopay: ошибка проверки статуса', order_id=order_id, error=e)
|
||||
return None
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Mixin для интеграции с Donut P2P (gw.donut.business)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.donut_service import donut_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг description при PayIn (Donut) <-> наш sub-method id
|
||||
DONUT_METHOD_DESCRIPTIONS: dict[str | None, str] = {
|
||||
None: 'CARD',
|
||||
'card': 'CARD',
|
||||
'sbp': 'SBP',
|
||||
'sbp_qr': 'SBP_QR',
|
||||
}
|
||||
|
||||
|
||||
# Маппинг статусов Donut -> internal
|
||||
DONUT_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'created': ('pending', False),
|
||||
'processing': ('pending', False),
|
||||
'success': ('success', True),
|
||||
'cancelled': ('cancelled', False),
|
||||
'error': ('error', False),
|
||||
}
|
||||
|
||||
|
||||
class DonutPaymentMixin:
|
||||
"""Mixin для работы с платежами Donut."""
|
||||
|
||||
async def create_donut_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
payment_method_type: str | None = None,
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Создаёт платёж Donut."""
|
||||
if not settings.is_donut_enabled():
|
||||
logger.error('Donut не настроен')
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.DONUT_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Donut: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
DONUT_MIN_AMOUNT_KOPEKS=settings.DONUT_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.DONUT_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Donut: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
DONUT_MAX_AMOUNT_KOPEKS=settings.DONUT_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
order_id = f'dnt{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.DONUT_CURRENCY
|
||||
|
||||
method_key = (payment_method_type or '').lower() or None
|
||||
method_description = DONUT_METHOD_DESCRIPTIONS.get(method_key, 'CARD')
|
||||
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
'payment_method_type': method_key,
|
||||
'donut_description': method_description,
|
||||
}
|
||||
|
||||
try:
|
||||
callback_url = self._build_donut_callback_url()
|
||||
customer_id = str(tg_id) if tg_id != 'guest' else f'guest-{order_id[-6:]}'
|
||||
actual_return_url = return_url or settings.DONUT_RETURN_URL
|
||||
|
||||
api_result = await donut_service.create_payment(
|
||||
amount_rubles=amount_rubles,
|
||||
order_id=order_id,
|
||||
customer_id=customer_id,
|
||||
method_description=method_description,
|
||||
customer_email=email,
|
||||
customer_phone=getattr(user, 'phone', None) if user else None,
|
||||
callback_url=callback_url,
|
||||
return_url=actual_return_url,
|
||||
redirect=True,
|
||||
)
|
||||
|
||||
transaction_id = api_result.get('transaction_id')
|
||||
details = api_result.get('details') or {}
|
||||
payment_url = api_result.get('redirect_url') or details.get('qrcode_url') or actual_return_url
|
||||
|
||||
logger.info(
|
||||
'Donut: получен ответ API',
|
||||
order_id=order_id,
|
||||
transaction_id=transaction_id,
|
||||
payment_url=payment_url,
|
||||
method_description=method_description,
|
||||
)
|
||||
|
||||
lifetime = settings.DONUT_PAYMENT_LIFETIME_MINUTES
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
|
||||
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
local_payment = await donut_crud.create_donut_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=method_key,
|
||||
donut_transaction_id=str(transaction_id) if transaction_id else None,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Donut: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'payment_id': str(transaction_id) if transaction_id else None,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Donut: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_donut_callback_url() -> str | None:
|
||||
"""Собирает абсолютный callback URL для Donut."""
|
||||
webhook_path = settings.DONUT_WEBHOOK_PATH or '/donut-webhook'
|
||||
base = (
|
||||
getattr(settings, 'WEBHOOK_URL', None)
|
||||
or getattr(settings, 'WEB_API_BASE_URL', None)
|
||||
or getattr(settings, 'CABINET_URL', None)
|
||||
)
|
||||
if not base:
|
||||
return None
|
||||
return f'{base.rstrip("/")}{webhook_path if webhook_path.startswith("/") else "/" + webhook_path}'
|
||||
|
||||
async def process_donut_callback(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Обрабатывает callback от Donut (подпись уже проверена в webserver)."""
|
||||
try:
|
||||
our_order_id = payload.get('order_id')
|
||||
donut_transaction_id = payload.get('transaction_id')
|
||||
status_obj = payload.get('status') or {}
|
||||
donut_status = (status_obj.get('type') or '').strip().lower()
|
||||
|
||||
if not our_order_id or not donut_status:
|
||||
logger.warning('Donut callback: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
payment = await donut_crud.get_donut_payment_by_order_id(db, our_order_id)
|
||||
if not payment:
|
||||
logger.warning('Donut callback: платеж не найден', order_id=our_order_id)
|
||||
return False
|
||||
|
||||
locked = await donut_crud.get_donut_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Donut: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('Donut callback: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Терминальные неуспешные статусы стики — провайдер не должен иметь возможность
|
||||
# «починить» отклонённый/несовпавший платёж повторным callback'ом.
|
||||
if payment.status in {'amount_mismatch', 'cancelled', 'error', 'expired'}:
|
||||
logger.warning(
|
||||
'Donut callback: платёж в терминальном неуспешном статусе, игнорируется',
|
||||
order_id=payment.order_id,
|
||||
current_status=payment.status,
|
||||
incoming_status=donut_status,
|
||||
)
|
||||
return True
|
||||
|
||||
internal_status, is_paid = DONUT_STATUS_MAP.get(donut_status, ('pending', False))
|
||||
|
||||
callback_payload = {
|
||||
'donut_transaction_id': donut_transaction_id,
|
||||
'status_type': donut_status,
|
||||
'amount': payload.get('amount'),
|
||||
'recalculated': payload.get('recalculated'),
|
||||
'timestamp': payload.get('timestamp'),
|
||||
}
|
||||
|
||||
if is_paid:
|
||||
amount_obj = payload.get('amount') or {}
|
||||
received_value = amount_obj.get('value')
|
||||
if received_value is not None:
|
||||
try:
|
||||
received_kopeks = round(float(received_value) * 100)
|
||||
except (TypeError, ValueError):
|
||||
received_kopeks = None
|
||||
if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'Donut amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await donut_crud.update_donut_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
if is_paid:
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.donut_transaction_id = (
|
||||
str(donut_transaction_id) if donut_transaction_id else payment.donut_transaction_id
|
||||
)
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_donut_payment(db, payment, trigger='webhook')
|
||||
|
||||
payment = await donut_crud.update_donut_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Donut callback: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_donut_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock уже взят вызывающим.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'Donut платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=payment.order_id,
|
||||
provider_name='donut',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для Donut', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = payment.order_id
|
||||
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.DONUT,
|
||||
)
|
||||
|
||||
display_name = settings.get_donut_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.DONUT,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await donut_crud.link_donut_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('Donut платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.DONUT,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Donut', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления Donut', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'✅ <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю Donut', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан Donut платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_donut_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
transaction_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Запрос статуса платежа через API Donut."""
|
||||
try:
|
||||
return await donut_service.check_payment(transaction_id=transaction_id)
|
||||
except Exception as e:
|
||||
logger.error('Donut: ошибка проверки статуса', transaction_id=transaction_id, error=e)
|
||||
return None
|
||||
@@ -0,0 +1,518 @@
|
||||
"""Mixin для интеграции с Etoplatezhi (paymentpage.etoplatezhi.ru)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.etoplatezhi_service import etoplatezhi_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов Etoplatezhi -> internal
|
||||
ETOPLATEZHI_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'success': ('success', True),
|
||||
'decline': ('declined', False),
|
||||
'error': ('error', False),
|
||||
'processing': ('pending', False),
|
||||
'awaiting 3ds result': ('pending', False),
|
||||
'awaiting redirect result': ('pending', False),
|
||||
'awaiting clarification': ('pending', False),
|
||||
'awaiting customer action': ('pending', False),
|
||||
'cancelled': ('cancelled', False),
|
||||
'refunded': ('refunded', False),
|
||||
'partially refunded': ('partially_refunded', False),
|
||||
'reversed': ('reversed', False),
|
||||
}
|
||||
|
||||
|
||||
class EtoplatezhiPaymentMixin:
|
||||
"""Mixin для работы с платежами Etoplatezhi."""
|
||||
|
||||
async def create_etoplatezhi_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
payment_method_type: str | None = None,
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж Etoplatezhi.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_etoplatezhi_enabled():
|
||||
logger.error('Etoplatezhi не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Etoplatezhi: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
ETOPLATEZHI_MIN_AMOUNT_KOPEKS=settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Etoplatezhi: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
ETOPLATEZHI_MAX_AMOUNT_KOPEKS=settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'etp{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.ETOPLATEZHI_CURRENCY
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
try:
|
||||
# Формируем webhook URL
|
||||
webhook_url = None
|
||||
if settings.WEBHOOK_URL:
|
||||
webhook_url = f'{settings.WEBHOOK_URL.rstrip("/")}{settings.ETOPLATEZHI_WEBHOOK_PATH}'
|
||||
|
||||
lifetime = settings.ETOPLATEZHI_PAYMENT_LIFETIME_MINUTES
|
||||
|
||||
# Определяем force_payment_method по типу подметода
|
||||
force_method = None
|
||||
if payment_method_type == 'sbp':
|
||||
force_method = 'sbp'
|
||||
elif payment_method_type == 'card':
|
||||
force_method = 'card'
|
||||
|
||||
# Строим URL для редиректа на платёжную страницу
|
||||
payment_url = etoplatezhi_service.build_payment_url(
|
||||
project_id=settings.ETOPLATEZHI_PROJECT_ID or 0,
|
||||
payment_id=order_id,
|
||||
payment_amount=amount_kopeks,
|
||||
payment_currency=currency,
|
||||
customer_id=str(tg_id),
|
||||
description=description,
|
||||
callback_url=webhook_url,
|
||||
success_url=return_url or settings.ETOPLATEZHI_RETURN_URL,
|
||||
fail_url=return_url or settings.ETOPLATEZHI_RETURN_URL,
|
||||
force_payment_method=force_method,
|
||||
customer_email=email,
|
||||
language_code=language,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Etoplatezhi: сформирован URL платежа',
|
||||
order_id=order_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
|
||||
|
||||
# Сохраняем в БД
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
local_payment = await etoplatezhi_crud.create_etoplatezhi_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method_type,
|
||||
etoplatezhi_payment_id=None,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Etoplatezhi: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Etoplatezhi: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
async def process_etoplatezhi_callback(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает callback от Etoplatezhi.
|
||||
|
||||
Подпись проверяется в webserver/payments.py до вызова этого метода.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело callback (signature проверена в webserver)
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
# Etoplatezhi callback structure:
|
||||
# {project_id, payment: {id, status, sum: {amount, currency}}, customer: {id}, signature}
|
||||
payment_data = payload.get('payment', {})
|
||||
etoplatezhi_payment_id = payment_data.get('id')
|
||||
etoplatezhi_status = payment_data.get('status')
|
||||
|
||||
# payment.id в callback — это наш payment_id (order_id)
|
||||
our_payment_id = str(etoplatezhi_payment_id) if etoplatezhi_payment_id else None
|
||||
|
||||
if not our_payment_id or not etoplatezhi_status:
|
||||
logger.warning('Etoplatezhi callback: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Определяем is_paid по статусу
|
||||
is_confirmed = etoplatezhi_status == 'success'
|
||||
|
||||
# Ищем платеж по order_id (наш payment_id = order_id)
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
payment = await etoplatezhi_crud.get_etoplatezhi_payment_by_order_id(db, our_payment_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'Etoplatezhi callback: платеж не найден',
|
||||
payment_id=our_payment_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await etoplatezhi_crud.get_etoplatezhi_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Etoplatezhi: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Проверка дублирования (re-check from locked row)
|
||||
if payment.is_paid:
|
||||
logger.info('Etoplatezhi callback: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = ETOPLATEZHI_STATUS_MAP.get(etoplatezhi_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
# Если статус success, принудительно считаем оплаченным
|
||||
if is_confirmed:
|
||||
is_paid = True
|
||||
internal_status = 'success'
|
||||
|
||||
# Извлекаем сумму из callback: payment.sum.amount (в минорных единицах)
|
||||
sum_data = payment_data.get('sum', {})
|
||||
|
||||
callback_payload = {
|
||||
'etoplatezhi_payment_id': etoplatezhi_payment_id,
|
||||
'status': etoplatezhi_status,
|
||||
'sum': sum_data,
|
||||
'customer': payload.get('customer'),
|
||||
'project_id': payload.get('project_id'),
|
||||
}
|
||||
|
||||
# Проверка суммы ДО обновления статуса
|
||||
if is_paid:
|
||||
amount_value = sum_data.get('amount')
|
||||
if amount_value is not None:
|
||||
# amount в минорных единицах (копейках)
|
||||
received_kopeks = int(amount_value)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'Etoplatezhi amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await etoplatezhi_crud.update_etoplatezhi_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
# Финализируем платеж если оплачен — без промежуточного commit
|
||||
if is_paid:
|
||||
# Inline field assignments to keep FOR UPDATE lock intact
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.etoplatezhi_payment_id = str(etoplatezhi_payment_id) if etoplatezhi_payment_id else None
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_etoplatezhi_payment(db, payment, trigger='webhook')
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
payment = await etoplatezhi_crud.update_etoplatezhi_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Etoplatezhi callback: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_etoplatezhi_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock must be acquired by the caller before invoking this method.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'Etoplatezhi платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
# Read fresh metadata AFTER lock to avoid stale data
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
# --- Guest purchase flow ---
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=payment.order_id,
|
||||
provider_name='etoplatezhi',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
# Ensure paid fields are set (idempotent — caller may have already set them)
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для Etoplatezhi', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
# Загружаем промогруппы в асинхронном контексте
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = payment.order_id
|
||||
|
||||
# Проверяем дупликат транзакции
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.ETOPLATEZHI,
|
||||
)
|
||||
|
||||
display_name = settings.get_etoplatezhi_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.ETOPLATEZHI,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await etoplatezhi_crud.link_etoplatezhi_payment_to_transaction(
|
||||
db, payment=payment, transaction_id=transaction.id
|
||||
)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('Etoplatezhi платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.ETOPLATEZHI,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Etoplatezhi', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления Etoplatezhi', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'\u2705 <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю Etoplatezhi', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан Etoplatezhi платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,494 @@
|
||||
"""Mixin для интеграции с Jupiter (FPGate P2P v2.1, app.juppiter.tech)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.jupiter_service import jupiter_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов Jupiter -> internal
|
||||
JUPITER_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'success': ('success', True),
|
||||
'processing': ('pending', False),
|
||||
'cancelled': ('cancelled', False),
|
||||
'declined': ('declined', False),
|
||||
'error': ('error', False),
|
||||
}
|
||||
|
||||
|
||||
class JupiterPaymentMixin:
|
||||
"""Mixin для работы с платежами Jupiter."""
|
||||
|
||||
async def create_jupiter_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
payment_method_type: str | None = None,
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Создаёт платёж Jupiter.
|
||||
|
||||
Параметр ``return_url`` принимается для совместимости сигнатуры с другими провайдерами
|
||||
(cabinet/routes/balance.py одинаково вызывает все ``create_*_payment``), но фактически
|
||||
не используется: спецификация Jupiter v2.1 имеет поле ``redirect`` зарезервированное
|
||||
для будущего использования и не поддерживает return-URL семантику. Пользователь
|
||||
видит QR-код СБП и подтверждает оплату в банковском приложении.
|
||||
"""
|
||||
if not settings.is_jupiter_enabled():
|
||||
logger.error('Jupiter не настроен')
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.JUPITER_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Jupiter: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
JUPITER_MIN_AMOUNT_KOPEKS=settings.JUPITER_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.JUPITER_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Jupiter: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
JUPITER_MAX_AMOUNT_KOPEKS=settings.JUPITER_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
order_id = f'jup{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.JUPITER_CURRENCY
|
||||
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
'payment_method_type': payment_method_type,
|
||||
}
|
||||
|
||||
try:
|
||||
callback_url = self._build_jupiter_callback_url()
|
||||
customer_id = str(tg_id) if tg_id != 'guest' else f'guest-{order_id[-6:]}'
|
||||
customer_name = (
|
||||
getattr(user, 'first_name', None) or getattr(user, 'username', None) or settings.JUPITER_FALLBACK_NAME
|
||||
)
|
||||
|
||||
api_result = await jupiter_service.create_payment(
|
||||
amount_rubles=amount_rubles,
|
||||
order_id=order_id,
|
||||
customer_id=customer_id,
|
||||
customer_email=email,
|
||||
customer_name=customer_name,
|
||||
callback_url=callback_url,
|
||||
description=description[:255] if description else None,
|
||||
)
|
||||
|
||||
transaction_id = api_result.get('transaction_id')
|
||||
details = api_result.get('details') or {}
|
||||
payment_url = details.get('qrcode_url') or details.get('url')
|
||||
|
||||
logger.info(
|
||||
'Jupiter: получен ответ API',
|
||||
order_id=order_id,
|
||||
transaction_id=transaction_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
lifetime = settings.JUPITER_PAYMENT_LIFETIME_MINUTES
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
|
||||
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
local_payment = await jupiter_crud.create_jupiter_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method_type,
|
||||
jupiter_transaction_id=str(transaction_id) if transaction_id else None,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Jupiter: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'payment_id': str(transaction_id) if transaction_id else None,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Jupiter: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_jupiter_callback_url() -> str | None:
|
||||
"""Собирает абсолютный callback URL для Jupiter."""
|
||||
webhook_path = settings.JUPITER_WEBHOOK_PATH or '/jupiter-webhook'
|
||||
base = (
|
||||
getattr(settings, 'WEBHOOK_URL', None)
|
||||
or getattr(settings, 'WEB_API_BASE_URL', None)
|
||||
or getattr(settings, 'CABINET_URL', None)
|
||||
)
|
||||
if not base:
|
||||
return None
|
||||
return f'{base.rstrip("/")}{webhook_path if webhook_path.startswith("/") else "/" + webhook_path}'
|
||||
|
||||
async def process_jupiter_callback(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Обрабатывает callback от Jupiter (подпись уже проверена в webserver)."""
|
||||
try:
|
||||
our_order_id = payload.get('order_id')
|
||||
jupiter_transaction_id = payload.get('transaction_id')
|
||||
status_obj = payload.get('status') or {}
|
||||
jupiter_status = (status_obj.get('type') or '').strip().lower()
|
||||
|
||||
if not our_order_id or not jupiter_status:
|
||||
logger.warning('Jupiter callback: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
payment = await jupiter_crud.get_jupiter_payment_by_order_id(db, our_order_id)
|
||||
if not payment:
|
||||
logger.warning('Jupiter callback: платеж не найден', order_id=our_order_id)
|
||||
return False
|
||||
|
||||
locked = await jupiter_crud.get_jupiter_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Jupiter: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('Jupiter callback: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Терминальные неуспешные статусы стики — провайдер не должен иметь возможность
|
||||
# «починить» отклонённый/несовпавший платёж повторным callback'ом.
|
||||
if payment.status in {'amount_mismatch', 'cancelled', 'declined', 'error', 'expired'}:
|
||||
logger.warning(
|
||||
'Jupiter callback: платёж в терминальном неуспешном статусе, игнорируется',
|
||||
order_id=payment.order_id,
|
||||
current_status=payment.status,
|
||||
incoming_status=jupiter_status,
|
||||
)
|
||||
return True
|
||||
|
||||
internal_status, is_paid = JUPITER_STATUS_MAP.get(jupiter_status, ('pending', False))
|
||||
|
||||
callback_payload = {
|
||||
'jupiter_transaction_id': jupiter_transaction_id,
|
||||
'status_type': jupiter_status,
|
||||
'amount': payload.get('amount'),
|
||||
'recalculated': payload.get('recalculated'),
|
||||
'timestamp': payload.get('timestamp'),
|
||||
}
|
||||
|
||||
# Сверяем сумму ДО обновления статуса
|
||||
if is_paid:
|
||||
amount_obj = payload.get('amount') or {}
|
||||
received_value = amount_obj.get('value')
|
||||
if received_value is not None:
|
||||
try:
|
||||
received_kopeks = round(float(received_value) * 100)
|
||||
except (TypeError, ValueError):
|
||||
received_kopeks = None
|
||||
if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'Jupiter amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await jupiter_crud.update_jupiter_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
if is_paid:
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.jupiter_transaction_id = (
|
||||
str(jupiter_transaction_id) if jupiter_transaction_id else payment.jupiter_transaction_id
|
||||
)
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_jupiter_payment(db, payment, trigger='webhook')
|
||||
|
||||
payment = await jupiter_crud.update_jupiter_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Jupiter callback: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_jupiter_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock уже взят вызывающим.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'Jupiter платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=payment.order_id,
|
||||
provider_name='jupiter',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для Jupiter', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = payment.order_id
|
||||
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.JUPITER,
|
||||
)
|
||||
|
||||
display_name = settings.get_jupiter_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.JUPITER,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await jupiter_crud.link_jupiter_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('Jupiter платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.JUPITER,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Jupiter', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления Jupiter', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'✅ <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю Jupiter', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан Jupiter платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_jupiter_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
transaction_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Запрос статуса платежа через API Jupiter."""
|
||||
try:
|
||||
return await jupiter_service.check_payment(transaction_id=transaction_id)
|
||||
except Exception as e:
|
||||
logger.error('Jupiter: ошибка проверки статуса', transaction_id=transaction_id, error=e)
|
||||
return None
|
||||
@@ -0,0 +1,590 @@
|
||||
"""Mixin для интеграции с Lava Business (gate.lava.ru)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.lava_service import lava_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг sub-method -> includeService для фильтрации методов на странице оплаты Lava
|
||||
LAVA_INCLUDE_SERVICE_MAP: dict[str | None, list[str] | None] = {
|
||||
None: None,
|
||||
'card': ['card'],
|
||||
'sbp': ['sbp'],
|
||||
}
|
||||
|
||||
|
||||
# Маппинг статусов Lava -> internal
|
||||
LAVA_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'created': ('pending', False),
|
||||
'pending': ('pending', False),
|
||||
'processing': ('pending', False), # на случай промежуточного статуса
|
||||
'success': ('success', True),
|
||||
'cancel': ('cancelled', False),
|
||||
'cancelled': ('cancelled', False),
|
||||
'expired': ('expired', False),
|
||||
'error': ('error', False),
|
||||
'failed': ('failed', False),
|
||||
}
|
||||
|
||||
|
||||
class LavaPaymentMixin:
|
||||
"""Mixin для работы с платежами Lava Business."""
|
||||
|
||||
async def create_lava_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
payment_method_type: str | None = None,
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Создаёт инвойс Lava."""
|
||||
if not settings.is_lava_enabled():
|
||||
logger.error('Lava не настроен')
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.LAVA_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Lava: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
LAVA_MIN_AMOUNT_KOPEKS=settings.LAVA_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.LAVA_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Lava: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
LAVA_MAX_AMOUNT_KOPEKS=settings.LAVA_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
# 32 hex char (128 бит) суффикс — order_id уникален даже при публичном tg_id
|
||||
order_id = f'lava{tg_id}_{uuid.uuid4().hex}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.LAVA_CURRENCY
|
||||
|
||||
method_key = (payment_method_type or '').lower() or None
|
||||
include_service = LAVA_INCLUDE_SERVICE_MAP.get(method_key)
|
||||
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
'payment_method_type': method_key,
|
||||
'email': email,
|
||||
}
|
||||
|
||||
try:
|
||||
hook_url = self._build_lava_hook_url()
|
||||
if not hook_url:
|
||||
logger.warning(
|
||||
'Lava: hook_url не сконфигурирован — '
|
||||
'платёж создаётся, но автоматическое подтверждение через webhook невозможно. '
|
||||
'Установите WEBHOOK_URL / WEB_API_BASE_URL / CABINET_URL.'
|
||||
)
|
||||
actual_return_url = return_url or settings.LAVA_RETURN_URL
|
||||
|
||||
api_result = await lava_service.create_invoice(
|
||||
amount_rubles=amount_rubles,
|
||||
order_id=order_id,
|
||||
hook_url=hook_url,
|
||||
success_url=actual_return_url,
|
||||
fail_url=actual_return_url,
|
||||
expire_minutes=settings.LAVA_PAYMENT_LIFETIME_MINUTES,
|
||||
comment=(description or '')[:255] or None,
|
||||
custom_fields=str(user_id) if user_id is not None else None,
|
||||
include_service=include_service,
|
||||
)
|
||||
|
||||
data = (api_result.get('data') or api_result) if isinstance(api_result, dict) else {}
|
||||
lava_invoice_id = data.get('id') or data.get('invoice_id')
|
||||
payment_url = data.get('url') or data.get('payment_url')
|
||||
expired_str = data.get('expired')
|
||||
|
||||
if not payment_url:
|
||||
# Без URL у пользователя нет способа оплатить — это аномалия Lava API.
|
||||
# Row не сохраняем, чтобы не плодить «зависшие» pending-инвойсы без реквизитов.
|
||||
logger.error(
|
||||
'Lava: ответ API без payment URL, инвойс не создан',
|
||||
order_id=order_id,
|
||||
lava_invoice_id=lava_invoice_id,
|
||||
response_keys=list(data.keys()) if isinstance(data, dict) else None,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'Lava: получен ответ API',
|
||||
order_id=order_id,
|
||||
lava_invoice_id=lava_invoice_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
lifetime = settings.LAVA_PAYMENT_LIFETIME_MINUTES
|
||||
expires_at = self._parse_lava_expired(expired_str) or (datetime.now(UTC) + timedelta(minutes=lifetime))
|
||||
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
local_payment = await lava_crud.create_lava_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=method_key,
|
||||
lava_invoice_id=str(lava_invoice_id) if lava_invoice_id else None,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Lava: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'payment_id': str(lava_invoice_id) if lava_invoice_id else None,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Lava: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_lava_expired(value: Any) -> datetime | None:
|
||||
"""Парсит поле ``expired`` из ответа Lava.
|
||||
|
||||
Принимаются только TZ-aware строки (ISO с offset/Z) или unix timestamp.
|
||||
Naive-строки игнорируются — TZ Lava в спеке не задокументирована,
|
||||
а угадывание UTC может сместить срок жизни инвойса на несколько часов.
|
||||
Если парсинг не удался — caller использует fallback ``now + lifetime``.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value), tz=UTC)
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
from dateutil.parser import isoparse # type: ignore[import-not-found]
|
||||
|
||||
parsed = isoparse(value)
|
||||
if parsed.tzinfo is None:
|
||||
return None # без TZ доверять не можем
|
||||
return parsed
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_lava_hook_url() -> str | None:
|
||||
"""Собирает абсолютный URL вебхука для Lava."""
|
||||
webhook_path = settings.LAVA_WEBHOOK_PATH or '/lava-webhook'
|
||||
base = (
|
||||
getattr(settings, 'WEBHOOK_URL', None)
|
||||
or getattr(settings, 'WEB_API_BASE_URL', None)
|
||||
or getattr(settings, 'CABINET_URL', None)
|
||||
)
|
||||
if not base:
|
||||
return None
|
||||
suffix = webhook_path if webhook_path.startswith('/') else f'/{webhook_path}'
|
||||
return f'{base.rstrip("/")}{suffix}'
|
||||
|
||||
async def process_lava_callback(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Обрабатывает webhook от Lava (подпись уже проверена в webserver)."""
|
||||
try:
|
||||
lava_invoice_id = payload.get('invoice_id')
|
||||
our_order_id = payload.get('order_id')
|
||||
lava_status = (payload.get('status') or '').strip().lower()
|
||||
pay_service = payload.get('pay_service')
|
||||
|
||||
if not our_order_id or not lava_status:
|
||||
logger.warning('Lava webhook: отсутствуют обязательные поля')
|
||||
return False
|
||||
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
payment = await lava_crud.get_lava_payment_by_order_id(db, our_order_id)
|
||||
if not payment:
|
||||
# Fallback по invoice_id, но строго проверяем совпадение order_id
|
||||
if lava_invoice_id:
|
||||
payment = await lava_crud.get_lava_payment_by_invoice_id(db, str(lava_invoice_id))
|
||||
if payment and payment.order_id != our_order_id:
|
||||
logger.error(
|
||||
'Lava webhook: order_id mismatch',
|
||||
webhook_order_id=our_order_id,
|
||||
record_order_id=payment.order_id,
|
||||
invoice_id=lava_invoice_id,
|
||||
)
|
||||
return False
|
||||
if not payment:
|
||||
logger.warning('Lava webhook: платеж не найден', order_id=our_order_id)
|
||||
return False
|
||||
|
||||
locked = await lava_crud.get_lava_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Lava: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('Lava webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Терминальные неуспешные статусы — стики, защита от повторного успеха
|
||||
if payment.status in {'amount_mismatch', 'cancelled', 'cancel', 'error', 'expired', 'failed'}:
|
||||
# Если внезапно пришёл success после терминальной неудачи — это сигнал
|
||||
# подделки или ошибки на стороне Lava, эскалируем.
|
||||
if lava_status == 'success':
|
||||
logger.error(
|
||||
'Lava webhook: success на терминально-неуспешном платеже, игнорируется',
|
||||
order_id=payment.order_id,
|
||||
current_status=payment.status,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
'Lava webhook: платёж в терминальном неуспешном статусе, игнорируется',
|
||||
order_id=payment.order_id,
|
||||
current_status=payment.status,
|
||||
incoming_status=lava_status,
|
||||
)
|
||||
return True
|
||||
|
||||
if lava_status not in LAVA_STATUS_MAP:
|
||||
logger.warning(
|
||||
'Lava webhook: неизвестный статус, обрабатываем как pending',
|
||||
order_id=payment.order_id,
|
||||
incoming_status=lava_status,
|
||||
)
|
||||
internal_status, is_paid = LAVA_STATUS_MAP.get(lava_status, ('pending', False))
|
||||
|
||||
callback_payload = {
|
||||
'lava_invoice_id': lava_invoice_id,
|
||||
'status': lava_status,
|
||||
'amount': payload.get('amount'),
|
||||
'credited': payload.get('credited'),
|
||||
'pay_service': pay_service,
|
||||
'pay_time': payload.get('pay_time'),
|
||||
'payer_details': payload.get('payer_details'),
|
||||
'custom_fields': payload.get('custom_fields'),
|
||||
}
|
||||
|
||||
# Сверяем сумму ДО зачисления
|
||||
if is_paid:
|
||||
# Lava webhook содержит amount (сумма счёта в рублях, float).
|
||||
# Сверяем с тем, что мы отправляли на создание.
|
||||
received_amount = payload.get('amount')
|
||||
if received_amount is not None:
|
||||
try:
|
||||
received_kopeks = round(float(received_amount) * 100)
|
||||
except (TypeError, ValueError):
|
||||
received_kopeks = None
|
||||
if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'Lava amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await lava_crud.update_lava_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
if is_paid:
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if lava_invoice_id and not payment.lava_invoice_id:
|
||||
payment.lava_invoice_id = str(lava_invoice_id)
|
||||
# Сохраняем pay_service в metadata, не перезаписывая user-выбранный payment_method
|
||||
if pay_service:
|
||||
metadata_now = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
metadata_now['actual_pay_service'] = str(pay_service).lower()
|
||||
payment.metadata_json = metadata_now
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_lava_payment(db, payment, trigger='webhook')
|
||||
|
||||
payment = await lava_crud.update_lava_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Lava webhook: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_lava_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE-lock уже взят вызывающим.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'Lava платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=payment.order_id,
|
||||
provider_name='lava',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для Lava', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = payment.order_id
|
||||
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.LAVA,
|
||||
)
|
||||
|
||||
display_name = settings.get_lava_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.LAVA,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await lava_crud.link_lava_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('Lava платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.LAVA,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Lava', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления Lava', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'✅ <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю Lava', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан Lava платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_lava_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str | None = None,
|
||||
invoice_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Запрос статуса инвойса через API Lava."""
|
||||
try:
|
||||
return await lava_service.get_invoice_status(order_id=order_id, invoice_id=invoice_id)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Lava: ошибка проверки статуса',
|
||||
order_id=order_id,
|
||||
invoice_id=invoice_id,
|
||||
error=e,
|
||||
)
|
||||
return None
|
||||
@@ -558,8 +558,8 @@ class Pal24PaymentMixin:
|
||||
payment_id_str = str(payment.payment_id)
|
||||
try:
|
||||
payment_response = await service.get_payment_status(payment_id_str)
|
||||
except Pal24APIError as error:
|
||||
logger.error('Ошибка Pal24 API при получении статуса платежа', error=error)
|
||||
except Pal24APIError:
|
||||
logger.debug('Pal24 payment_id не найден или невалиден', payment_id=payment_id_str)
|
||||
else:
|
||||
if payment_response:
|
||||
remote_payloads['payment_status'] = payment_response
|
||||
@@ -569,8 +569,8 @@ class Pal24PaymentMixin:
|
||||
|
||||
try:
|
||||
payments_response = await service.get_bill_payments(bill_id_str)
|
||||
except Pal24APIError as error:
|
||||
logger.error('Ошибка Pal24 API при получении списка платежей', error=error)
|
||||
except Pal24APIError:
|
||||
logger.debug('Pal24 bill payments не найдены', bill_id=bill_id_str)
|
||||
else:
|
||||
if payments_response:
|
||||
remote_payloads['bill_payments'] = payments_response
|
||||
|
||||
@@ -189,6 +189,57 @@ def _get_method_defaults() -> dict:
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
'etoplatezhi': {
|
||||
'default_display_name': settings.get_etoplatezhi_display_name(),
|
||||
'is_configured': settings.is_etoplatezhi_enabled(),
|
||||
'default_min': settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
'antilopay': {
|
||||
'default_display_name': settings.get_antilopay_display_name(),
|
||||
'is_configured': settings.is_antilopay_enabled(),
|
||||
'default_min': settings.ANTILOPAY_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.ANTILOPAY_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
{'id': 'sberpay', 'name': 'SberPay'},
|
||||
],
|
||||
},
|
||||
'jupiter': {
|
||||
'default_display_name': settings.get_jupiter_display_name(),
|
||||
'is_configured': settings.is_jupiter_enabled(),
|
||||
'default_min': settings.JUPITER_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.JUPITER_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
'donut': {
|
||||
'default_display_name': settings.get_donut_display_name(),
|
||||
'is_configured': settings.is_donut_enabled(),
|
||||
'default_min': settings.DONUT_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.DONUT_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
{'id': 'sbp_qr', 'name': 'СБП QR'},
|
||||
],
|
||||
},
|
||||
'lava': {
|
||||
'default_display_name': settings.get_lava_display_name(),
|
||||
'is_configured': settings.is_lava_enabled(),
|
||||
'default_min': settings.LAVA_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.LAVA_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +286,11 @@ DEFAULT_METHOD_ORDER = [
|
||||
'rollypay',
|
||||
'overpay',
|
||||
'aurapay',
|
||||
'etoplatezhi',
|
||||
'antilopay',
|
||||
'jupiter',
|
||||
'donut',
|
||||
'lava',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -15,17 +15,25 @@ from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.types import String as SAString
|
||||
|
||||
from app.database.models import (
|
||||
AntilopayPayment,
|
||||
AuraPayPayment,
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
DonutPayment,
|
||||
EtoplatezhiPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
JupiterPayment,
|
||||
KassaAiPayment,
|
||||
LavaPayment,
|
||||
MulenPayPayment,
|
||||
OverpayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethod,
|
||||
PayPearPayment,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
RollyPayPayment,
|
||||
SeverPayPayment,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
@@ -683,6 +691,280 @@ async def _search_overpay(db: AsyncSession, params: SearchParams) -> list[Pendin
|
||||
return records
|
||||
|
||||
|
||||
async def _search_paypear(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(PayPearPayment).options(selectinload(PayPearPayment.user)).order_by(desc(PayPearPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, PayPearPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
PayPearPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
PayPearPayment.paypear_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, PayPearPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.PAYPEAR,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_rollypay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(RollyPayPayment).options(selectinload(RollyPayPayment.user)).order_by(desc(RollyPayPayment.created_at))
|
||||
)
|
||||
stmt = _apply_date_filter(stmt, RollyPayPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
RollyPayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
RollyPayPayment.rollypay_payment_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, RollyPayPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.ROLLYPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_aurapay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(AuraPayPayment).options(selectinload(AuraPayPayment.user)).order_by(desc(AuraPayPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, AuraPayPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
AuraPayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
AuraPayPayment.aurapay_invoice_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, AuraPayPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.AURAPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_etoplatezhi(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(EtoplatezhiPayment)
|
||||
.options(selectinload(EtoplatezhiPayment.user))
|
||||
.order_by(desc(EtoplatezhiPayment.created_at))
|
||||
)
|
||||
stmt = _apply_date_filter(stmt, EtoplatezhiPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
EtoplatezhiPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
EtoplatezhiPayment.etoplatezhi_payment_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, EtoplatezhiPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.ETOPLATEZHI,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_antilopay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(AntilopayPayment)
|
||||
.options(selectinload(AntilopayPayment.user))
|
||||
.order_by(desc(AntilopayPayment.created_at))
|
||||
)
|
||||
stmt = _apply_date_filter(stmt, AntilopayPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
AntilopayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
AntilopayPayment.antilopay_payment_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, AntilopayPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.ANTILOPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_jupiter(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(JupiterPayment).options(selectinload(JupiterPayment.user)).order_by(desc(JupiterPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, JupiterPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
JupiterPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
JupiterPayment.jupiter_transaction_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, JupiterPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.JUPITER,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_donut(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(DonutPayment).options(selectinload(DonutPayment.user)).order_by(desc(DonutPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, DonutPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
DonutPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
DonutPayment.donut_transaction_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, DonutPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.DONUT,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_lava(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(LavaPayment).options(selectinload(LavaPayment.user)).order_by(desc(LavaPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, LavaPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
LavaPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
LavaPayment.lava_invoice_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, LavaPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.LAVA,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_stars(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
@@ -737,6 +1019,14 @@ _PROVIDER_SEARCH_MAP: dict[PaymentMethod, Any] = {
|
||||
PaymentMethod.RIOPAY: _search_riopay,
|
||||
PaymentMethod.SEVERPAY: _search_severpay,
|
||||
PaymentMethod.OVERPAY: _search_overpay,
|
||||
PaymentMethod.PAYPEAR: _search_paypear,
|
||||
PaymentMethod.ROLLYPAY: _search_rollypay,
|
||||
PaymentMethod.AURAPAY: _search_aurapay,
|
||||
PaymentMethod.ETOPLATEZHI: _search_etoplatezhi,
|
||||
PaymentMethod.ANTILOPAY: _search_antilopay,
|
||||
PaymentMethod.JUPITER: _search_jupiter,
|
||||
PaymentMethod.DONUT: _search_donut,
|
||||
PaymentMethod.LAVA: _search_lava,
|
||||
PaymentMethod.TELEGRAM_STARS: _search_stars,
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,15 @@ from app.services.payment import (
|
||||
WataPaymentMixin,
|
||||
YooKassaPaymentMixin,
|
||||
)
|
||||
from app.services.payment.antilopay import AntilopayPaymentMixin
|
||||
from app.services.payment.aurapay import AuraPayPaymentMixin
|
||||
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
|
||||
from app.services.payment.donut import DonutPaymentMixin
|
||||
from app.services.payment.etoplatezhi import EtoplatezhiPaymentMixin
|
||||
from app.services.payment.freekassa import FreekassaPaymentMixin
|
||||
from app.services.payment.jupiter import JupiterPaymentMixin
|
||||
from app.services.payment.kassa_ai import KassaAiPaymentMixin
|
||||
from app.services.payment.lava import LavaPaymentMixin
|
||||
from app.services.payment.overpay import OverpayPaymentMixin
|
||||
from app.services.payment.paypear import PayPearPaymentMixin
|
||||
from app.services.payment.riopay import RioPayPaymentMixin
|
||||
@@ -482,6 +487,181 @@ async def link_aurapay_payment_to_transaction(*args, **kwargs):
|
||||
return await aurapay_crud.link_aurapay_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_etoplatezhi_payment(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.create_etoplatezhi_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_order_id(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.get_etoplatezhi_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_invoice_id(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.get_etoplatezhi_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_id(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.get_etoplatezhi_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_id_for_update(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.get_etoplatezhi_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_etoplatezhi_payment_status(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.update_etoplatezhi_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_etoplatezhi_payment_to_transaction(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.link_etoplatezhi_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_antilopay_payment(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.create_antilopay_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_order_id(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.get_antilopay_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_invoice_id(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.get_antilopay_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_id(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.get_antilopay_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_id_for_update(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.get_antilopay_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_antilopay_payment_status(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.update_antilopay_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_antilopay_payment_to_transaction(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.link_antilopay_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_jupiter_payment(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.create_jupiter_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_order_id(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.get_jupiter_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_invoice_id(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.get_jupiter_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_id(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.get_jupiter_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_id_for_update(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.get_jupiter_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_jupiter_payment_status(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.update_jupiter_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_jupiter_payment_to_transaction(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.link_jupiter_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_donut_payment(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.create_donut_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_donut_payment_by_order_id(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.get_donut_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_donut_payment_by_invoice_id(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.get_donut_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_donut_payment_by_id(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.get_donut_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_donut_payment_by_id_for_update(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.get_donut_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_donut_payment_status(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.update_donut_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_donut_payment_to_transaction(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.link_donut_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_lava_payment(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.create_lava_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_lava_payment_by_order_id(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.get_lava_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_lava_payment_by_invoice_id(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.get_lava_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_lava_payment_by_id(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.get_lava_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_lava_payment_by_id_for_update(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.get_lava_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_lava_payment_status(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.update_lava_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_lava_payment_to_transaction(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.link_lava_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
# Mapping from model_name to getter function name for providers
|
||||
# where it differs from the standard get_{model_name}_payment_by_id pattern.
|
||||
_GETTER_OVERRIDES: dict[str, str] = {
|
||||
@@ -509,6 +689,11 @@ class PaymentService(
|
||||
RollyPayPaymentMixin,
|
||||
OverpayPaymentMixin,
|
||||
AuraPayPaymentMixin,
|
||||
EtoplatezhiPaymentMixin,
|
||||
AntilopayPaymentMixin,
|
||||
JupiterPaymentMixin,
|
||||
DonutPaymentMixin,
|
||||
LavaPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
|
||||
@@ -1016,6 +1201,116 @@ class PaymentService(
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Etoplatezhi ------------------------------------------------------
|
||||
if payment_method == 'etoplatezhi':
|
||||
if not settings.is_etoplatezhi_enabled():
|
||||
logger.warning('Etoplatezhi is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_etoplatezhi_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'etoplatezhi')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'etoplatezhi',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Antilopay --------------------------------------------------------
|
||||
if payment_method == 'antilopay':
|
||||
if not settings.is_antilopay_enabled():
|
||||
logger.warning('Antilopay is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_antilopay_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'antilopay')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'antilopay',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Jupiter ----------------------------------------------------------
|
||||
if payment_method == 'jupiter':
|
||||
if not settings.is_jupiter_enabled():
|
||||
logger.warning('Jupiter is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_jupiter_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'jupiter')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'jupiter',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Donut ------------------------------------------------------------
|
||||
if payment_method == 'donut':
|
||||
if not settings.is_donut_enabled():
|
||||
logger.warning('Donut is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_donut_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'donut')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'donut',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Lava -------------------------------------------------------------
|
||||
if payment_method == 'lava':
|
||||
if not settings.is_lava_enabled():
|
||||
logger.warning('Lava is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_lava_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'lava')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'lava',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Telegram Stars ---------------------------------------------------
|
||||
if payment_method == 'telegram_stars':
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
|
||||
@@ -18,16 +18,24 @@ from sqlalchemy.orm import selectinload
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import (
|
||||
AntilopayPayment,
|
||||
AuraPayPayment,
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
DonutPayment,
|
||||
EtoplatezhiPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
JupiterPayment,
|
||||
KassaAiPayment,
|
||||
LavaPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethod,
|
||||
PayPearPayment,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
RollyPayPayment,
|
||||
SeverPayPayment,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
@@ -77,6 +85,11 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.RIOPAY,
|
||||
PaymentMethod.SEVERPAY,
|
||||
PaymentMethod.OVERPAY,
|
||||
PaymentMethod.PAYPEAR,
|
||||
PaymentMethod.ROLLYPAY,
|
||||
PaymentMethod.AURAPAY,
|
||||
# ETOPLATEZHI / ANTILOPAY / JUPITER / DONUT / LAVA — webhook-driven,
|
||||
# без API-метода синхронизации БД, manual check не реализован.
|
||||
}
|
||||
)
|
||||
|
||||
@@ -98,6 +111,9 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.RIOPAY,
|
||||
PaymentMethod.SEVERPAY,
|
||||
PaymentMethod.OVERPAY,
|
||||
PaymentMethod.PAYPEAR,
|
||||
PaymentMethod.ROLLYPAY,
|
||||
PaymentMethod.AURAPAY,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -129,6 +145,22 @@ def method_display_name(method: PaymentMethod) -> str:
|
||||
return settings.get_severpay_display_name()
|
||||
if method == PaymentMethod.OVERPAY:
|
||||
return settings.get_overpay_display_name()
|
||||
if method == PaymentMethod.PAYPEAR:
|
||||
return settings.get_paypear_display_name()
|
||||
if method == PaymentMethod.ROLLYPAY:
|
||||
return settings.get_rollypay_display_name()
|
||||
if method == PaymentMethod.AURAPAY:
|
||||
return settings.get_aurapay_display_name()
|
||||
if method == PaymentMethod.ETOPLATEZHI:
|
||||
return settings.get_etoplatezhi_display_name()
|
||||
if method == PaymentMethod.ANTILOPAY:
|
||||
return settings.get_antilopay_display_name()
|
||||
if method == PaymentMethod.JUPITER:
|
||||
return settings.get_jupiter_display_name()
|
||||
if method == PaymentMethod.DONUT:
|
||||
return settings.get_donut_display_name()
|
||||
if method == PaymentMethod.LAVA:
|
||||
return settings.get_lava_display_name()
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return 'Telegram Stars'
|
||||
return method.value
|
||||
@@ -161,6 +193,22 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
|
||||
return settings.is_severpay_enabled()
|
||||
if method == PaymentMethod.OVERPAY:
|
||||
return settings.is_overpay_enabled()
|
||||
if method == PaymentMethod.PAYPEAR:
|
||||
return settings.is_paypear_enabled()
|
||||
if method == PaymentMethod.ROLLYPAY:
|
||||
return settings.is_rollypay_enabled()
|
||||
if method == PaymentMethod.AURAPAY:
|
||||
return settings.is_aurapay_enabled()
|
||||
if method == PaymentMethod.ETOPLATEZHI:
|
||||
return settings.is_etoplatezhi_enabled()
|
||||
if method == PaymentMethod.ANTILOPAY:
|
||||
return settings.is_antilopay_enabled()
|
||||
if method == PaymentMethod.JUPITER:
|
||||
return settings.is_jupiter_enabled()
|
||||
if method == PaymentMethod.DONUT:
|
||||
return settings.is_donut_enabled()
|
||||
if method == PaymentMethod.LAVA:
|
||||
return settings.is_lava_enabled()
|
||||
return False
|
||||
|
||||
|
||||
@@ -410,6 +458,62 @@ def _is_riopay_pending(payment: RioPayPayment) -> bool:
|
||||
return status in {'pending'}
|
||||
|
||||
|
||||
def _is_paypear_pending(payment: PayPearPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_rollypay_pending(payment: RollyPayPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_aurapay_pending(payment: AuraPayPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_etoplatezhi_pending(payment: EtoplatezhiPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_antilopay_pending(payment: AntilopayPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_jupiter_pending(payment: JupiterPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_donut_pending(payment: DonutPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_lava_pending(payment: LavaPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
|
||||
payload = payment.payload or ''
|
||||
match = re.search(r'_(\d+)$', payload)
|
||||
@@ -779,6 +883,214 @@ async def _fetch_severpay_payments(db: AsyncSession, cutoff: datetime) -> list[P
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_paypear_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(PayPearPayment)
|
||||
.options(selectinload(PayPearPayment.user))
|
||||
.where(PayPearPayment.created_at >= cutoff)
|
||||
.order_by(desc(PayPearPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_paypear_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.PAYPEAR,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_rollypay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(RollyPayPayment)
|
||||
.options(selectinload(RollyPayPayment.user))
|
||||
.where(RollyPayPayment.created_at >= cutoff)
|
||||
.order_by(desc(RollyPayPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_rollypay_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.ROLLYPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_aurapay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(AuraPayPayment)
|
||||
.options(selectinload(AuraPayPayment.user))
|
||||
.where(AuraPayPayment.created_at >= cutoff)
|
||||
.order_by(desc(AuraPayPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_aurapay_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.AURAPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_etoplatezhi_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(EtoplatezhiPayment)
|
||||
.options(selectinload(EtoplatezhiPayment.user))
|
||||
.where(EtoplatezhiPayment.created_at >= cutoff)
|
||||
.order_by(desc(EtoplatezhiPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_etoplatezhi_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.ETOPLATEZHI,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_antilopay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(AntilopayPayment)
|
||||
.options(selectinload(AntilopayPayment.user))
|
||||
.where(AntilopayPayment.created_at >= cutoff)
|
||||
.order_by(desc(AntilopayPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_antilopay_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.ANTILOPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_jupiter_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(JupiterPayment)
|
||||
.options(selectinload(JupiterPayment.user))
|
||||
.where(JupiterPayment.created_at >= cutoff)
|
||||
.order_by(desc(JupiterPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_jupiter_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.JUPITER,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_donut_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(DonutPayment)
|
||||
.options(selectinload(DonutPayment.user))
|
||||
.where(DonutPayment.created_at >= cutoff)
|
||||
.order_by(desc(DonutPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_donut_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.DONUT,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_lava_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(LavaPayment)
|
||||
.options(selectinload(LavaPayment.user))
|
||||
.where(LavaPayment.created_at >= cutoff)
|
||||
.order_by(desc(LavaPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_lava_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.LAVA,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
@@ -828,6 +1140,14 @@ async def list_recent_pending_payments(
|
||||
await _fetch_kassa_ai_payments(db, cutoff),
|
||||
await _fetch_riopay_payments(db, cutoff),
|
||||
await _fetch_severpay_payments(db, cutoff),
|
||||
await _fetch_paypear_payments(db, cutoff),
|
||||
await _fetch_rollypay_payments(db, cutoff),
|
||||
await _fetch_aurapay_payments(db, cutoff),
|
||||
await _fetch_etoplatezhi_payments(db, cutoff),
|
||||
await _fetch_antilopay_payments(db, cutoff),
|
||||
await _fetch_jupiter_payments(db, cutoff),
|
||||
await _fetch_donut_payments(db, cutoff),
|
||||
await _fetch_lava_payments(db, cutoff),
|
||||
await _fetch_stars_transactions(db, cutoff),
|
||||
)
|
||||
|
||||
@@ -1026,6 +1346,126 @@ async def get_payment_record(
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.PAYPEAR:
|
||||
payment = await db.get(PayPearPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.ROLLYPAY:
|
||||
payment = await db.get(RollyPayPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.AURAPAY:
|
||||
payment = await db.get(AuraPayPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.ETOPLATEZHI:
|
||||
payment = await db.get(EtoplatezhiPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.ANTILOPAY:
|
||||
payment = await db.get(AntilopayPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.JUPITER:
|
||||
payment = await db.get(JupiterPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.DONUT:
|
||||
payment = await db.get(DonutPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.LAVA:
|
||||
payment = await db.get(LavaPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
transaction = await db.get(Transaction, local_payment_id)
|
||||
if not transaction:
|
||||
@@ -1098,6 +1538,27 @@ async def run_manual_check(
|
||||
payment = result.get('payment') if result else None
|
||||
else:
|
||||
payment = None
|
||||
elif method == PaymentMethod.PAYPEAR:
|
||||
paypear_payment = await db.get(PayPearPayment, local_payment_id)
|
||||
if paypear_payment:
|
||||
result = await payment_service.check_paypear_payment_status(db, paypear_payment.order_id)
|
||||
payment = result.get('payment') if result else None
|
||||
else:
|
||||
payment = None
|
||||
elif method == PaymentMethod.ROLLYPAY:
|
||||
rollypay_payment = await db.get(RollyPayPayment, local_payment_id)
|
||||
if rollypay_payment:
|
||||
result = await payment_service.check_rollypay_payment_status(db, rollypay_payment.order_id)
|
||||
payment = result.get('payment') if result else None
|
||||
else:
|
||||
payment = None
|
||||
elif method == PaymentMethod.AURAPAY:
|
||||
aurapay_payment = await db.get(AuraPayPayment, local_payment_id)
|
||||
if aurapay_payment:
|
||||
result = await payment_service.check_aurapay_payment_status(db, aurapay_payment.order_id)
|
||||
payment = result.get('payment') if result else None
|
||||
else:
|
||||
payment = None
|
||||
else:
|
||||
logger.warning('Manual check requested for unsupported method', method=method)
|
||||
return None
|
||||
|
||||
@@ -208,27 +208,57 @@ class PayPearService:
|
||||
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 documented webhook source IPs
|
||||
WEBHOOK_ALLOWED_IPS: set[str] = {'158.160.85.101'}
|
||||
|
||||
PayPear sends signature in the webhook JSON field 'signature'.
|
||||
The signature is HMAC-SHA256(secret_key, raw_body).
|
||||
def verify_webhook_signature(self, raw_body: bytes, received_signature: str, client_ip: str | None = None) -> bool:
|
||||
"""Верификация webhook PayPear.
|
||||
|
||||
PayPear documentation does not specify the exact signature algorithm.
|
||||
We try HMAC-SHA256(secret_key, body_without_signature_field) — the most common pattern.
|
||||
If signature verification fails, fall back to IP allowlist check (recommended by PayPear docs).
|
||||
"""
|
||||
try:
|
||||
if not received_signature:
|
||||
logger.warning('PayPear webhook: отсутствует signature')
|
||||
return False
|
||||
import json as json_mod
|
||||
|
||||
expected = hmac.new(
|
||||
self.secret_key.encode('utf-8'),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
# Try signature verification (body without 'signature' field, sorted keys, compact separators)
|
||||
if received_signature and self.secret_key:
|
||||
try:
|
||||
payload = json_mod.loads(raw_body)
|
||||
payload_without_sig = {k: v for k, v in payload.items() if k != 'signature'}
|
||||
body_to_sign = json_mod.dumps(payload_without_sig, separators=(',', ':'), sort_keys=True).encode(
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
return hmac.compare_digest(expected, received_signature)
|
||||
except Exception as e:
|
||||
logger.error('PayPear webhook verify error', error=e)
|
||||
return False
|
||||
expected = hmac.new(
|
||||
self.secret_key.encode('utf-8'),
|
||||
body_to_sign,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if hmac.compare_digest(expected, received_signature):
|
||||
return True
|
||||
|
||||
# Try without sort_keys (original key order)
|
||||
body_to_sign_unsorted = json_mod.dumps(payload_without_sig, separators=(',', ':')).encode('utf-8')
|
||||
expected_unsorted = hmac.new(
|
||||
self.secret_key.encode('utf-8'),
|
||||
body_to_sign_unsorted,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if hmac.compare_digest(expected_unsorted, received_signature):
|
||||
return True
|
||||
|
||||
logger.debug('PayPear signature mismatch, falling back to IP check')
|
||||
except Exception as e:
|
||||
logger.debug('PayPear signature verify error, falling back to IP check', error=e)
|
||||
|
||||
# Fallback: IP allowlist (recommended by PayPear docs)
|
||||
if client_ip and client_ip in self.WEBHOOK_ALLOWED_IPS:
|
||||
return True
|
||||
|
||||
logger.warning('PayPear webhook: signature mismatch and IP not in allowlist', client_ip=client_ip)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
|
||||
@@ -77,6 +77,9 @@ PERMISSION_REGISTRY: dict[str, list[str]] = {
|
||||
'pinned_messages': ['read', 'create', 'edit', 'delete'],
|
||||
'landings': ['read', 'create', 'edit', 'delete'],
|
||||
'updates': ['read', 'manage'],
|
||||
'bulk_actions': ['read', 'execute'],
|
||||
'info_pages': ['read', 'create', 'edit', 'delete'],
|
||||
'news': ['read', 'create', 'edit', 'delete'],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -603,6 +603,9 @@ class PricingEngine:
|
||||
period_pct = 0
|
||||
devices_pct = 0
|
||||
promo_group = self.resolve_promo_group(user)
|
||||
# Only apply promo group discount if the tariff is available for this group
|
||||
if promo_group is not None and not tariff.is_available_for_promo_group(promo_group.id):
|
||||
promo_group = None
|
||||
if promo_group is not None:
|
||||
period_pct = promo_group.get_discount_percent('period', period_days)
|
||||
devices_pct = promo_group.get_discount_percent('devices', period_days)
|
||||
@@ -612,9 +615,9 @@ class PricingEngine:
|
||||
discounted_base = self.apply_discount(base_price, period_pct)
|
||||
discounted_devices = self.apply_discount(devices_price, devices_pct)
|
||||
|
||||
# Traffic uses addon discount (checks apply_discounts_to_addons flag)
|
||||
# Traffic uses addon discount — but only if promo_group passed the tariff availability check
|
||||
discounted_traffic = traffic_price
|
||||
if traffic_price > 0 and user:
|
||||
if traffic_price > 0 and user and promo_group is not None:
|
||||
discounted_traffic, _, _ = self.calculate_traffic_discount(traffic_price, user)
|
||||
|
||||
base_group_disc = base_price - discounted_base
|
||||
|
||||
@@ -301,6 +301,10 @@ class ReferralContestService:
|
||||
lines.append('')
|
||||
lines.append(f'Приз: {html.escape(contest.prize_text)}')
|
||||
|
||||
# Respect per-category enable/disable
|
||||
if not getattr(settings, 'ADMIN_NOTIFICATIONS_PROMO_ENABLED', True):
|
||||
return
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
|
||||
@@ -99,6 +99,73 @@ async def clear_pending_referral(telegram_id: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pending campaign helpers (Redis)
|
||||
#
|
||||
# Mirrors pending_referral: lets us survive the case where /start <campaign>
|
||||
# stored campaign_id only in FSM, but the user opened the cabinet WebApp before
|
||||
# completing bot registration. The cabinet auth route reads this as a fallback
|
||||
# when the HTTP request didn't carry an explicit campaign_slug.
|
||||
# ---------------------------------------------------------------------------
|
||||
_PENDING_CAMPAIGN_TTL = 7 * 24 * 3600 # 7 days
|
||||
|
||||
|
||||
async def save_pending_campaign(telegram_id: int, campaign_slug: str, campaign_id: int) -> bool:
|
||||
"""Save pending campaign attribution to Redis for a not-yet-registered user.
|
||||
|
||||
Called from /start handler immediately after resolving an advertising campaign.
|
||||
Picked up by the cabinet auth route if the user opens the WebApp before
|
||||
completing the bot registration flow.
|
||||
"""
|
||||
client = _get_redis()
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
key = f'pending_campaign:{telegram_id}'
|
||||
data = json.dumps({'campaign_slug': campaign_slug, 'campaign_id': campaign_id})
|
||||
await client.setex(key, _PENDING_CAMPAIGN_TTL, data)
|
||||
logger.info(
|
||||
'Saved pending campaign to Redis',
|
||||
telegram_id=telegram_id,
|
||||
campaign_slug=campaign_slug,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to save pending campaign to Redis', error=exc)
|
||||
return False
|
||||
|
||||
|
||||
async def get_pending_campaign(telegram_id: int) -> dict[str, str | int] | None:
|
||||
"""Get pending campaign from Redis.
|
||||
|
||||
Returns ``{'campaign_slug': ..., 'campaign_id': ...}`` or ``None``.
|
||||
"""
|
||||
client = _get_redis()
|
||||
if client is None:
|
||||
return None
|
||||
try:
|
||||
key = f'pending_campaign:{telegram_id}'
|
||||
data = await client.get(key)
|
||||
if data:
|
||||
return json.loads(data)
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to get pending campaign from Redis', error=exc)
|
||||
return None
|
||||
|
||||
|
||||
async def clear_pending_campaign(telegram_id: int) -> None:
|
||||
"""Clear pending campaign after successful application."""
|
||||
client = _get_redis()
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
await client.delete(f'pending_campaign:{telegram_id}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _is_commission_limit_reached(db: AsyncSession, referrer_id: int, referral_id: int) -> bool:
|
||||
"""Проверяет, исчерпан ли лимит комиссионных платежей для пары реферер-реферал."""
|
||||
if settings.REFERRAL_MAX_COMMISSION_PAYMENTS <= 0:
|
||||
|
||||
@@ -2397,7 +2397,9 @@ class RemnaWaveService:
|
||||
user.remnawave_uuid = panel_uuid
|
||||
return ('updated', sub, None)
|
||||
except RemnaWaveAPIError as api_error:
|
||||
if api_error.status_code == 404:
|
||||
# A018 = "user not found" in some RemnaWave versions (may return 400 or 404)
|
||||
error_code = (api_error.response_data or {}).get('errorCode', '')
|
||||
if api_error.status_code == 404 or error_code == 'A018':
|
||||
new_user = await api.create_user(**create_kwargs)
|
||||
return ('created', sub, new_user)
|
||||
raise
|
||||
|
||||
@@ -994,6 +994,16 @@ class RemnaWaveWebhookService:
|
||||
async def _handle_user_deleted(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
# Suppress webhook if this deletion was initiated by delete_user_account —
|
||||
# prevents deadlock between the ongoing deletion transaction and this handler
|
||||
if self._is_intentional_panel_deletion_event(data):
|
||||
logger.info(
|
||||
'Webhook user.deleted suppressed — intentional panel deletion in progress',
|
||||
user_id=user.id,
|
||||
uuid=data.get('uuid'),
|
||||
)
|
||||
return
|
||||
|
||||
user_id = user.id
|
||||
sub_id = subscription.id if subscription else None
|
||||
|
||||
@@ -1075,8 +1085,8 @@ class RemnaWaveWebhookService:
|
||||
subscription.connected_squads = []
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
if settings.is_multi_tariff_enabled():
|
||||
subscription.remnawave_uuid = None
|
||||
# Always clear stale UUID — panel user was deleted
|
||||
subscription.remnawave_uuid = None
|
||||
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id))
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
@@ -1533,7 +1534,7 @@ async def _auto_add_devices(
|
||||
|
||||
# Recompute price fresh under lock (pricing config may have changed since cart was saved)
|
||||
devices_price_per_month = devices_to_add * tariff_device_price
|
||||
days_left = max(1, (subscription.end_date - datetime.now(UTC)).days)
|
||||
days_left = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
|
||||
devices_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
user,
|
||||
'devices',
|
||||
@@ -2137,7 +2138,7 @@ async def try_auto_extend_expired_after_topup(
|
||||
from app.database.crud.subscription import get_all_subscriptions_by_user_id
|
||||
|
||||
all_subs = await get_all_subscriptions_by_user_id(db, user.id)
|
||||
expired_subs = [s for s in all_subs if s.status == SubscriptionStatus.EXPIRED.value and not s.is_trial]
|
||||
expired_subs = [s for s in all_subs if s.status == SubscriptionStatus.EXPIRED.value and s.is_trial is False]
|
||||
if not expired_subs:
|
||||
subscription = None
|
||||
else:
|
||||
@@ -2153,9 +2154,10 @@ async def try_auto_extend_expired_after_topup(
|
||||
return False
|
||||
|
||||
# Only process expired subscriptions (not trial, not disabled)
|
||||
# NULL-safe: is_trial can be None in legacy rows — treat as trial
|
||||
if subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
return False
|
||||
if subscription.is_trial:
|
||||
if subscription.is_trial is not False:
|
||||
return False
|
||||
|
||||
# Only process subscriptions expired within the last 30 days
|
||||
|
||||
@@ -70,8 +70,8 @@ class ReadOnlySettingError(RuntimeError):
|
||||
class BotConfigurationService:
|
||||
EXCLUDED_KEYS: set[str] = {'BOT_TOKEN', 'ADMIN_IDS'}
|
||||
|
||||
READ_ONLY_KEYS: set[str] = {'EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'}
|
||||
PLAIN_TEXT_KEYS: set[str] = {'EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'}
|
||||
READ_ONLY_KEYS: set[str] = set()
|
||||
PLAIN_TEXT_KEYS: set[str] = set()
|
||||
|
||||
CATEGORY_TITLES: dict[str, str] = {
|
||||
'CORE': '🤖 Основные настройки',
|
||||
@@ -95,13 +95,17 @@ class BotConfigurationService:
|
||||
'ROLLYPAY': '💳 RollyPay',
|
||||
'OVERPAY': '💳 Overpay',
|
||||
'AURAPAY': '💳 AuraPay',
|
||||
'ANTILOPAY': '🦌 Antilopay',
|
||||
'ETOPLATEZHI': '💳 Etoplatezhi',
|
||||
'JUPITER': '🪐 Jupiter',
|
||||
'DONUT': '🍩 Donut',
|
||||
'LAVA': '🌋 Lava',
|
||||
'YOOKASSA': '🟣 YooKassa',
|
||||
'PLATEGA': '💳 {platega_name}',
|
||||
'TRIBUTE': '🎁 Tribute',
|
||||
'MULENPAY': '💰 {mulenpay_name}',
|
||||
'PAL24': '🏦 PAL24 / PayPalych',
|
||||
'WATA': '💠 Wata',
|
||||
'EXTERNAL_ADMIN': '🛡️ Внешняя админка',
|
||||
'SUBSCRIPTIONS_CORE': '📅 Подписки и лимиты',
|
||||
'SIMPLE_SUBSCRIPTION': '⚡ Простая покупка',
|
||||
'PERIODS': '📆 Периоды подписок',
|
||||
@@ -160,6 +164,11 @@ class BotConfigurationService:
|
||||
'ROLLYPAY': 'RollyPay: платёжный шлюз rollypay.io с СБП, картами и криптовалютой.',
|
||||
'OVERPAY': 'Overpay: платёжный шлюз pay.overpay.io с mTLS и поддержкой карт и СБП.',
|
||||
'AURAPAY': 'AuraPay: платёжный шлюз aurapay.tech с поддержкой карт и СБП.',
|
||||
'ANTILOPAY': 'Antilopay: lk.antilopay.com, оплата картой, СБП и SberPay.',
|
||||
'ETOPLATEZHI': 'Etoplatezhi: paymentpage.etoplatezhi.ru, оплата картой и через СБП.',
|
||||
'JUPITER': 'Jupiter (FPGate P2P v2.1): app.juppiter.tech, эквайринг СБП с HMAC-SHA256.',
|
||||
'DONUT': 'Donut P2P: gw.donut.business, P2P-оплата картой, СБП по телефону и QR.',
|
||||
'LAVA': 'Lava Business: gate.lava.ru, оплата картой и СБП с HMAC-SHA256 и подтверждением через webhook.',
|
||||
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
|
||||
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
|
||||
'PAL24': 'PAL24 / PayPalych подключения и лимиты.',
|
||||
@@ -168,7 +177,6 @@ class BotConfigurationService:
|
||||
'TELEGRAM_WIDGET': 'Внешний вид виджета авторизации Telegram на странице входа в кабинет.',
|
||||
'TELEGRAM_OIDC': 'OpenID Connect авторизация через Telegram (новая система). Требует настройки в BotFather > Bot Settings > Web Login.',
|
||||
'WATA': 'Wata: токен доступа, тип платежа и пределы сумм.',
|
||||
'EXTERNAL_ADMIN': 'Токен внешней админки для проверки запросов.',
|
||||
'SUBSCRIPTIONS_CORE': 'Лимиты устройств, трафика и базовые цены подписок.',
|
||||
'SIMPLE_SUBSCRIPTION': 'Параметры упрощённой покупки: период, трафик, устройства и сквады.',
|
||||
'PERIODS': 'Доступные периоды подписок и продлений.',
|
||||
@@ -375,13 +383,17 @@ class BotConfigurationService:
|
||||
'ROLLYPAY_': 'ROLLYPAY',
|
||||
'OVERPAY_': 'OVERPAY',
|
||||
'AURAPAY_': 'AURAPAY',
|
||||
'ANTILOPAY_': 'ANTILOPAY',
|
||||
'ETOPLATEZHI_': 'ETOPLATEZHI',
|
||||
'JUPITER_': 'JUPITER',
|
||||
'DONUT_': 'DONUT',
|
||||
'LAVA_': 'LAVA',
|
||||
'PLATEGA_': 'PLATEGA',
|
||||
'MULENPAY_': 'MULENPAY',
|
||||
'PAL24_': 'PAL24',
|
||||
'PAYMENT_': 'PAYMENT',
|
||||
'PAYMENT_VERIFICATION_': 'PAYMENT_VERIFICATION',
|
||||
'WATA_': 'WATA',
|
||||
'EXTERNAL_ADMIN_': 'EXTERNAL_ADMIN',
|
||||
'SIMPLE_SUBSCRIPTION_': 'SIMPLE_SUBSCRIPTION',
|
||||
'CONNECT_BUTTON_HAPP': 'HAPP',
|
||||
'HAPP_': 'HAPP',
|
||||
@@ -738,20 +750,6 @@ class BotConfigurationService:
|
||||
'Если результат пустой, используется user_{telegram_id}.'
|
||||
),
|
||||
},
|
||||
'EXTERNAL_ADMIN_TOKEN': {
|
||||
'description': 'Приватный токен, который использует внешняя админка для проверки запросов.',
|
||||
'format': 'Значение генерируется автоматически из username бота и его токена и доступно только для чтения.',
|
||||
'example': 'Генерируется автоматически',
|
||||
'warning': 'Токен обновится при смене username или токена бота.',
|
||||
'dependencies': 'Username телеграм-бота, токен бота',
|
||||
},
|
||||
'EXTERNAL_ADMIN_TOKEN_BOT_ID': {
|
||||
'description': 'Идентификатор телеграм-бота, с которым связан токен внешней админки.',
|
||||
'format': 'Проставляется автоматически после первого запуска и не редактируется вручную.',
|
||||
'example': '123456789',
|
||||
'warning': 'Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.',
|
||||
'dependencies': 'Результат вызова getMe() в Telegram Bot API',
|
||||
},
|
||||
'TRIAL_USER_TAG': {
|
||||
'description': (
|
||||
'Тег, который бот передаст пользователю при активации триальной подписки в панели RemnaWave.'
|
||||
|
||||
@@ -184,6 +184,187 @@ def get_available_payment_methods() -> list[dict[str, str]]:
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_severpay_enabled():
|
||||
severpay_name = settings.get_severpay_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'severpay',
|
||||
'name': f'Банковская карта ({severpay_name})',
|
||||
'icon': '💳',
|
||||
'description': f'через {severpay_name}',
|
||||
'callback': 'topup_severpay',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_paypear_enabled():
|
||||
paypear_name = settings.get_paypear_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'paypear',
|
||||
'name': paypear_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {paypear_name}',
|
||||
'callback': 'topup_paypear',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_rollypay_enabled():
|
||||
rollypay_name = settings.get_rollypay_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'rollypay',
|
||||
'name': rollypay_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {rollypay_name}',
|
||||
'callback': 'topup_rollypay',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_overpay_enabled():
|
||||
overpay_name = settings.get_overpay_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'overpay',
|
||||
'name': overpay_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {overpay_name}',
|
||||
'callback': 'topup_overpay',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_aurapay_sbp_enabled():
|
||||
sbp_name = settings.get_aurapay_sbp_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'aurapay_sbp',
|
||||
'name': sbp_name,
|
||||
'icon': '📱',
|
||||
'description': f'через {sbp_name}',
|
||||
'callback': 'topup_aurapay_sbp',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_aurapay_card_enabled():
|
||||
card_name = settings.get_aurapay_card_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'aurapay_card',
|
||||
'name': card_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {card_name}',
|
||||
'callback': 'topup_aurapay_card',
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
settings.is_aurapay_enabled()
|
||||
and not settings.is_aurapay_sbp_enabled()
|
||||
and not settings.is_aurapay_card_enabled()
|
||||
):
|
||||
aurapay_name = settings.get_aurapay_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'aurapay',
|
||||
'name': aurapay_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {aurapay_name}',
|
||||
'callback': 'topup_aurapay',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_etoplatezhi_sbp_enabled():
|
||||
sbp_name = settings.get_etoplatezhi_sbp_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'etoplatezhi_sbp',
|
||||
'name': sbp_name,
|
||||
'icon': '📱',
|
||||
'description': f'через {sbp_name}',
|
||||
'callback': 'topup_etoplatezhi_sbp',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_etoplatezhi_card_enabled():
|
||||
card_name = settings.get_etoplatezhi_card_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'etoplatezhi_card',
|
||||
'name': card_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {card_name}',
|
||||
'callback': 'topup_etoplatezhi_card',
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
settings.is_etoplatezhi_enabled()
|
||||
and not settings.is_etoplatezhi_sbp_enabled()
|
||||
and not settings.is_etoplatezhi_card_enabled()
|
||||
):
|
||||
etoplatezhi_name = settings.get_etoplatezhi_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'etoplatezhi',
|
||||
'name': etoplatezhi_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {etoplatezhi_name}',
|
||||
'callback': 'topup_etoplatezhi',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_antilopay_sbp_enabled():
|
||||
sbp_name = settings.get_antilopay_sbp_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'antilopay_sbp',
|
||||
'name': sbp_name,
|
||||
'icon': '📱',
|
||||
'description': f'через {sbp_name}',
|
||||
'callback': 'topup_antilopay_sbp',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_antilopay_card_enabled():
|
||||
card_name = settings.get_antilopay_card_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'antilopay_card',
|
||||
'name': card_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {card_name}',
|
||||
'callback': 'topup_antilopay_card',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_antilopay_sberpay_enabled():
|
||||
sberpay_name = settings.get_antilopay_sberpay_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'antilopay_sberpay',
|
||||
'name': sberpay_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {sberpay_name}',
|
||||
'callback': 'topup_antilopay_sberpay',
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
settings.is_antilopay_enabled()
|
||||
and not settings.is_antilopay_sbp_enabled()
|
||||
and not settings.is_antilopay_card_enabled()
|
||||
and not settings.is_antilopay_sberpay_enabled()
|
||||
):
|
||||
antilopay_name = settings.get_antilopay_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'antilopay',
|
||||
'name': antilopay_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {antilopay_name}',
|
||||
'callback': 'topup_antilopay',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_support_topup_enabled():
|
||||
methods.append(
|
||||
{
|
||||
@@ -311,6 +492,34 @@ def is_payment_method_available(method_id: str) -> bool:
|
||||
return settings.is_kassa_ai_enabled()
|
||||
if method_id == 'riopay':
|
||||
return settings.is_riopay_enabled()
|
||||
if method_id == 'severpay':
|
||||
return settings.is_severpay_enabled()
|
||||
if method_id == 'paypear':
|
||||
return settings.is_paypear_enabled()
|
||||
if method_id == 'rollypay':
|
||||
return settings.is_rollypay_enabled()
|
||||
if method_id == 'overpay':
|
||||
return settings.is_overpay_enabled()
|
||||
if method_id == 'aurapay':
|
||||
return settings.is_aurapay_enabled()
|
||||
if method_id == 'aurapay_sbp':
|
||||
return settings.is_aurapay_sbp_enabled()
|
||||
if method_id == 'aurapay_card':
|
||||
return settings.is_aurapay_card_enabled()
|
||||
if method_id == 'etoplatezhi':
|
||||
return settings.is_etoplatezhi_enabled()
|
||||
if method_id == 'etoplatezhi_sbp':
|
||||
return settings.is_etoplatezhi_sbp_enabled()
|
||||
if method_id == 'etoplatezhi_card':
|
||||
return settings.is_etoplatezhi_card_enabled()
|
||||
if method_id == 'antilopay':
|
||||
return settings.is_antilopay_enabled()
|
||||
if method_id == 'antilopay_sbp':
|
||||
return settings.is_antilopay_sbp_enabled()
|
||||
if method_id == 'antilopay_card':
|
||||
return settings.is_antilopay_card_enabled()
|
||||
if method_id == 'antilopay_sberpay':
|
||||
return settings.is_antilopay_sberpay_enabled()
|
||||
if method_id == 'support':
|
||||
return settings.is_support_topup_enabled()
|
||||
return False
|
||||
@@ -333,6 +542,21 @@ def get_payment_method_status() -> dict[str, bool]:
|
||||
'cloudpayments': settings.is_cloudpayments_enabled(),
|
||||
'freekassa': settings.is_freekassa_enabled(),
|
||||
'kassa_ai': settings.is_kassa_ai_enabled(),
|
||||
'riopay': settings.is_riopay_enabled(),
|
||||
'severpay': settings.is_severpay_enabled(),
|
||||
'paypear': settings.is_paypear_enabled(),
|
||||
'rollypay': settings.is_rollypay_enabled(),
|
||||
'overpay': settings.is_overpay_enabled(),
|
||||
'aurapay': settings.is_aurapay_enabled(),
|
||||
'aurapay_sbp': settings.is_aurapay_sbp_enabled(),
|
||||
'aurapay_card': settings.is_aurapay_card_enabled(),
|
||||
'etoplatezhi': settings.is_etoplatezhi_enabled(),
|
||||
'etoplatezhi_sbp': settings.is_etoplatezhi_sbp_enabled(),
|
||||
'etoplatezhi_card': settings.is_etoplatezhi_card_enabled(),
|
||||
'antilopay': settings.is_antilopay_enabled(),
|
||||
'antilopay_sbp': settings.is_antilopay_sbp_enabled(),
|
||||
'antilopay_card': settings.is_antilopay_card_enabled(),
|
||||
'antilopay_sberpay': settings.is_antilopay_sberpay_enabled(),
|
||||
'support': settings.is_support_topup_enabled(),
|
||||
}
|
||||
|
||||
@@ -366,4 +590,20 @@ def get_enabled_payment_methods_count() -> int:
|
||||
count += 1
|
||||
if settings.is_kassa_ai_enabled():
|
||||
count += 1
|
||||
if settings.is_riopay_enabled():
|
||||
count += 1
|
||||
if settings.is_severpay_enabled():
|
||||
count += 1
|
||||
if settings.is_paypear_enabled():
|
||||
count += 1
|
||||
if settings.is_rollypay_enabled():
|
||||
count += 1
|
||||
if settings.is_overpay_enabled():
|
||||
count += 1
|
||||
if settings.is_aurapay_enabled():
|
||||
count += 1
|
||||
if settings.is_etoplatezhi_enabled():
|
||||
count += 1
|
||||
if settings.is_antilopay_enabled():
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
@@ -19,14 +20,14 @@ def calculate_months_from_days(days: int) -> int:
|
||||
return max(1, round(days / 30))
|
||||
|
||||
|
||||
def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_days: int = 30) -> tuple[int, int]:
|
||||
def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_days: int = 1) -> tuple[int, int]:
|
||||
"""Calculate prorated price based on remaining days.
|
||||
|
||||
Returns:
|
||||
tuple of (total_price_kopeks, days_charged)
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
days_remaining = max(1, (end_date - now).days)
|
||||
days_remaining = max(1, math.ceil((end_date - now).total_seconds() / 86400))
|
||||
days_to_charge = max(min_charge_days, days_remaining)
|
||||
|
||||
total_price = monthly_price * days_to_charge // 30
|
||||
|
||||
@@ -5755,7 +5755,7 @@ async def update_subscription_servers_endpoint(
|
||||
subscription.end_date,
|
||||
)
|
||||
else:
|
||||
charged_days = max(1, (subscription.end_date - datetime.now(UTC)).days)
|
||||
charged_days = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
|
||||
|
||||
added_server_ids = [catalog[uuid].get('server_id') for uuid in added if catalog[uuid].get('server_id') is not None]
|
||||
added_server_prices = [
|
||||
@@ -5933,7 +5933,7 @@ async def update_subscription_traffic_endpoint(
|
||||
},
|
||||
)
|
||||
|
||||
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
|
||||
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
|
||||
period_hint_days = days_remaining
|
||||
|
||||
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
|
||||
@@ -6111,7 +6111,7 @@ async def update_subscription_devices_endpoint(
|
||||
chargeable_diff = new_chargeable - current_chargeable
|
||||
|
||||
price_per_month = chargeable_diff * tariff_device_price
|
||||
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
|
||||
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
|
||||
period_hint_days = days_remaining
|
||||
|
||||
# Lock user BEFORE price computation to prevent TOCTOU on promo discount
|
||||
@@ -6159,7 +6159,7 @@ async def update_subscription_devices_endpoint(
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price_to_charge,
|
||||
description=f'{description} за {charged_days or max(1, (subscription.end_date - datetime.now(UTC)).days)} дн.',
|
||||
description=f'{description} за {charged_days or max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))} дн.',
|
||||
)
|
||||
|
||||
if price_to_charge > 0:
|
||||
|
||||
+247
-2
@@ -1268,8 +1268,13 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
|
||||
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')
|
||||
client_ip = (
|
||||
request.headers.get('x-real-ip')
|
||||
or request.headers.get('x-forwarded-for', '').split(',')[0].strip()
|
||||
or (request.client.host if request.client else None)
|
||||
)
|
||||
if not paypear_service.verify_webhook_signature(raw_body, received_signature, client_ip=client_ip):
|
||||
logger.warning('PayPear webhook: invalid signature and IP', client_ip=client_ip)
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
try:
|
||||
@@ -1464,6 +1469,241 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Etoplatezhi webhook
|
||||
if settings.is_etoplatezhi_enabled():
|
||||
|
||||
@router.get(settings.ETOPLATEZHI_WEBHOOK_PATH)
|
||||
async def etoplatezhi_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'etoplatezhi_webhook',
|
||||
'enabled': settings.is_etoplatezhi_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.ETOPLATEZHI_WEBHOOK_PATH)
|
||||
async def etoplatezhi_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Etoplatezhi webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Подпись внутри JSON body (поле signature)
|
||||
from app.services.etoplatezhi_service import etoplatezhi_service
|
||||
|
||||
if not etoplatezhi_service.verify_callback_signature(payload):
|
||||
logger.warning('Etoplatezhi webhook: invalid signature')
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_etoplatezhi_callback',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Etoplatezhi webhook processing failed',
|
||||
data=payload.get('payment', {}).get('id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Etoplatezhi webhook processing error', error=e)
|
||||
# Always return 200 — Etoplatezhi expects 200 for valid signature
|
||||
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Antilopay webhook
|
||||
if settings.is_antilopay_enabled():
|
||||
|
||||
@router.get(settings.ANTILOPAY_WEBHOOK_PATH)
|
||||
async def antilopay_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'antilopay_webhook',
|
||||
'enabled': settings.is_antilopay_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.ANTILOPAY_WEBHOOK_PATH)
|
||||
async def antilopay_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Antilopay webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Подпись в заголовке X-Apay-Callback, верифицируется публичным ключом
|
||||
from app.services.antilopay_service import antilopay_service
|
||||
|
||||
callback_signature = request.headers.get('X-Apay-Callback') or ''
|
||||
if not antilopay_service.verify_callback_signature(raw_body, callback_signature):
|
||||
logger.warning('Antilopay webhook: invalid signature')
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_antilopay_callback',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Antilopay webhook processing failed',
|
||||
data=payload.get('payment_id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Antilopay webhook processing error', error=e)
|
||||
# Always return 200 — Antilopay retries every 3min for 1hr on non-200
|
||||
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Jupiter webhook (FPGate P2P v2.1)
|
||||
if settings.is_jupiter_enabled():
|
||||
|
||||
@router.get(settings.JUPITER_WEBHOOK_PATH)
|
||||
async def jupiter_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'jupiter_webhook',
|
||||
'enabled': settings.is_jupiter_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.JUPITER_WEBHOOK_PATH)
|
||||
async def jupiter_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Jupiter webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from app.services.jupiter_service import jupiter_service
|
||||
|
||||
if not jupiter_service.verify_callback_signature(payload):
|
||||
logger.warning('Jupiter webhook: invalid signature')
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_jupiter_callback',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Jupiter webhook processing failed',
|
||||
transaction_id=payload.get('transaction_id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Jupiter webhook processing error', error=e)
|
||||
# FPGate ожидает HTTP 200 как подтверждение приёма callback
|
||||
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Lava webhook (Lava Business)
|
||||
if settings.is_lava_enabled():
|
||||
|
||||
@router.get(settings.LAVA_WEBHOOK_PATH)
|
||||
async def lava_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'lava_webhook',
|
||||
'enabled': settings.is_lava_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.LAVA_WEBHOOK_PATH)
|
||||
async def lava_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Lava webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from app.services.lava_service import lava_service
|
||||
|
||||
received_signature = (request.headers.get('Authorization') or '').strip()
|
||||
if not lava_service.verify_webhook_signature(raw_body, received_signature):
|
||||
logger.warning('Lava webhook: invalid signature')
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_lava_callback',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Lava webhook processing failed',
|
||||
order_id=payload.get('order_id'),
|
||||
invoice_id=payload.get('invoice_id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Lava webhook processing error', error=e)
|
||||
# Lava ожидает HTTP 200 как подтверждение приёма; иначе будет повтор до 5 раз раз в 150с
|
||||
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Donut webhook (Donut P2P)
|
||||
if settings.is_donut_enabled():
|
||||
|
||||
@router.get(settings.DONUT_WEBHOOK_PATH)
|
||||
async def donut_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'donut_webhook',
|
||||
'enabled': settings.is_donut_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.DONUT_WEBHOOK_PATH)
|
||||
async def donut_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Donut webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from app.services.donut_service import donut_service
|
||||
|
||||
if not donut_service.verify_callback_signature(payload):
|
||||
logger.warning('Donut webhook: invalid signature')
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_donut_callback',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Donut webhook processing failed',
|
||||
transaction_id=payload.get('transaction_id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Donut webhook processing error', error=e)
|
||||
# Donut ожидает HTTP 200 как подтверждение приёма callback
|
||||
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
if routes_registered:
|
||||
|
||||
@router.get('/health/payment-webhooks')
|
||||
@@ -1488,6 +1728,11 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
'rollypay_enabled': settings.is_rollypay_enabled(),
|
||||
'overpay_enabled': settings.is_overpay_enabled(),
|
||||
'aurapay_enabled': settings.is_aurapay_enabled(),
|
||||
'etoplatezhi_enabled': settings.is_etoplatezhi_enabled(),
|
||||
'antilopay_enabled': settings.is_antilopay_enabled(),
|
||||
'jupiter_enabled': settings.is_jupiter_enabled(),
|
||||
'donut_enabled': settings.is_donut_enabled(),
|
||||
'lava_enabled': settings.is_lava_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user