Compare commits
214 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b377c52fd9 | |||
| 7bbf9fbc63 | |||
| 31e3ccd24c | |||
| 0d0646770d | |||
| 17732a0370 | |||
| cd8be32671 | |||
| afea054c8f | |||
| f321ded9c0 | |||
| 3fce64858c | |||
| 1ab1ff90bf | |||
| 719664208e | |||
| 6524f66da2 | |||
| 17ac3da3c4 | |||
| e85c40f8cd | |||
| ecde2fb8f0 | |||
| 99648a956e | |||
| 2478ff7c3d | |||
| 2385814d77 | |||
| 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 | |||
| 5ed9a0d4fb | |||
| 63e1127353 | |||
| ab4661b5c6 | |||
| 52bf2a9589 | |||
| 68d2350dfd | |||
| 7d512d214a | |||
| 2cde38c63b | |||
| 24dc8d2a5e | |||
| 1522d35f2d | |||
| 2b0d8a2a88 | |||
| 9217f4116f | |||
| 2e45a93bd7 | |||
| d77fd81e16 | |||
| bdb8cab1c9 | |||
| d394565fe9 | |||
| 122d12db20 | |||
| 2071a680d3 | |||
| e4b4a54797 | |||
| 59c54c9b39 | |||
| 0d2b1dfdc9 | |||
| 605f202191 | |||
| be787a85bf | |||
| ff41ea9abb | |||
| cfbcc3082f | |||
| 2ad893badf | |||
| e78177b2fc | |||
| daa472570c | |||
| db7b6734fd | |||
| c0e0756b9a | |||
| 5b45d4354f | |||
| fb2773fee4 | |||
| ae7feeb726 | |||
| 70568f82c5 | |||
| 2c3ffc8c8a | |||
| 6f87563789 | |||
| 7005052156 | |||
| 29ae7089aa | |||
| 1068c1387a | |||
| d31632534b | |||
| 3272b4bb05 | |||
| dd177101f7 | |||
| 36571c4275 | |||
| 66f8577448 | |||
| 9ca3320a02 | |||
| 9ed4f086b0 | |||
| da855a7c89 | |||
| 7be404b918 | |||
| b71e58c8d2 | |||
| 1696e6f884 | |||
| 7093d368d3 | |||
| 905cea68b4 | |||
| dc5442223d | |||
| 3b03c253cc | |||
| db79cc9eb0 | |||
| 1b94d9e700 | |||
| 39a7c92cd4 | |||
| 81ebec676c | |||
| 25ea5c60fd | |||
| 29877fc93b | |||
| 5986c00fab | |||
| ecc4a6147d | |||
| 16bc1d4198 | |||
| 0f814be1b7 | |||
| 97179360c0 | |||
| 2aa5927433 | |||
| 1c696c69e3 | |||
| b531959982 | |||
| ead0fc99d3 | |||
| c09ae7436c | |||
| 1ea76575ce | |||
| 25447edc9e | |||
| a59858227f | |||
| ccc2f4efec | |||
| a18f6caa9b | |||
| 92eaf45311 | |||
| 61cf495fc5 | |||
| 0c545490b6 | |||
| 2d5afe5d75 | |||
| 50dc5a0fd1 | |||
| 4dc8b4c091 | |||
| 4db9e85062 | |||
| fca8d6da97 | |||
| 4fe67a9c74 | |||
| 30a1a31978 | |||
| c2b68e1afa | |||
| cea7260a85 | |||
| e3c0caabcf | |||
| aeaa4f8e0d | |||
| e226ac8637 | |||
| 85403da528 | |||
| bdd873382c | |||
| 9d750d9eb0 | |||
| 87b83f59c6 | |||
| 8ff6f99229 | |||
| 94da3c35b0 | |||
| d35a8bb74c | |||
| c635d88764 | |||
| 5009703676 | |||
| e88a5989b6 | |||
| 02747381dc | |||
| e74fda954c | |||
| 8587f03f67 | |||
| 4707cdf60c | |||
| 0879b8b218 | |||
| 1d91382b8e | |||
| 3768b18a39 | |||
| 1eeeb39779 | |||
| 570af82dfd | |||
| bc3893b934 | |||
| 16d91638bc | |||
| eb18b3a0f9 | |||
| a8e2b62f4b | |||
| fb8d2b3ee4 | |||
| 2321667ecb | |||
| 113304b212 | |||
| 0300044b00 | |||
| 931abfe7a5 | |||
| 1d96f80f60 | |||
| 4e50419171 | |||
| 7208a52c94 | |||
| 63fdfe4a42 | |||
| e0e2edf816 | |||
| 522a8779d6 | |||
| be32010d63 | |||
| 7e920fa30f | |||
| 1b376baeca | |||
| 91a756a33e | |||
| 970dc549df | |||
| 65120f0bad | |||
| 9cb559ff39 | |||
| 8f1882f24c | |||
| 8542a39305 | |||
| 646ac4cfa1 | |||
| abdf296767 | |||
| a1b6d9bb61 | |||
| cf19e4e1f7 | |||
| 35412e9f21 | |||
| 6aed7d355b | |||
| 9c08ce6948 | |||
| 862352139e | |||
| d465ccb3ac | |||
| b57f185258 | |||
| ffbb3fb8be | |||
| f01dbff000 | |||
| 31adcfded4 | |||
| 78f963bf5e | |||
| 357d94d1b0 | |||
| 0fb4a2c235 | |||
| 2f7184627a | |||
| d55e9db62a | |||
| 57adfaf4f3 | |||
| 4165eaea7a | |||
| 3b5d5a18a1 | |||
| eef41c4bca | |||
| 987c3c93c2 | |||
| 7d24e8d704 | |||
| 819f09a68e | |||
| 2f9d00343b | |||
| 9b7ac47f16 |
+84
-7
@@ -13,11 +13,15 @@ SUPPORT_USERNAME=@support
|
||||
# Имя пользователя бота (опционально, автоопределяется)
|
||||
# BOT_USERNAME=
|
||||
|
||||
# ===== SOCKS5 ПРОКСИ =====
|
||||
# ===== СЕТЬ И ПРОКСИ =====
|
||||
# URL SOCKS5 прокси-сервера для маршрутизации трафика бота к Telegram API
|
||||
# Формат: socks5://user:password@host:port или socks5://host:port
|
||||
# PROXY_URL=socks5://127.0.0.1:1080
|
||||
|
||||
# Альтернативный URL сервера Telegram Bot API (для регионов где api.telegram.org заблокирован)
|
||||
# Примеры: Cloudflare Worker, self-hosted telegram-bot-api (tdlib), любой совместимый прокси
|
||||
# TELEGRAM_API_URL=https://your-telegram-proxy.workers.dev
|
||||
|
||||
# ===== СИСТЕМА ПОДДЕРЖКИ =====
|
||||
# Включить меню поддержки в интерфейсе
|
||||
SUPPORT_MENU_ENABLED=true
|
||||
@@ -250,6 +254,18 @@ WEBHOOK_NOTIFY_DEVICES=true
|
||||
# - Подходит для продажи готовых пакетов услуг
|
||||
SALES_MODE=tariffs
|
||||
|
||||
# Управление сменой тарифа (для SALES_MODE=tariffs)
|
||||
# UPGRADE / DOWNGRADE:
|
||||
# true / true = все направления разрешены
|
||||
# true / false = только повышение (на более дорогой тариф)
|
||||
# false / true = только понижение (на более дешёвый тариф)
|
||||
# false / false = смена тарифа полностью отключена
|
||||
TARIFF_SWITCH_UPGRADE_ENABLED=true
|
||||
TARIFF_SWITCH_DOWNGRADE_ENABLED=true
|
||||
|
||||
# Сброс привязанных устройств при продлении подписки (однократно при каждом продлении)
|
||||
RESET_DEVICES_ON_RENEWAL=false
|
||||
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
@@ -680,6 +696,70 @@ RIOPAY_WEBHOOK_PATH=/riopay-webhook
|
||||
RIOPAY_SUCCESS_URL=
|
||||
RIOPAY_FAIL_URL=
|
||||
|
||||
# ===== SEVERPAY (severpay.io) =====
|
||||
SEVERPAY_ENABLED=false
|
||||
# Merchant ID
|
||||
SEVERPAY_MID=
|
||||
# Секретный токен для HMAC-SHA256
|
||||
SEVERPAY_TOKEN=
|
||||
SEVERPAY_DISPLAY_NAME=SeverPay
|
||||
SEVERPAY_CURRENCY=RUB
|
||||
SEVERPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
SEVERPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
SEVERPAY_WEBHOOK_PATH=/severpay-webhook
|
||||
# URL возврата после оплаты
|
||||
# SEVERPAY_RETURN_URL=
|
||||
# Время жизни платежа в минутах (30-4320)
|
||||
SEVERPAY_LIFETIME=1440
|
||||
|
||||
# ===== PAYPEAR (api.paypear.ru) =====
|
||||
PAYPEAR_ENABLED=false
|
||||
# Shop ID для HTTP Basic Auth
|
||||
PAYPEAR_SHOP_ID=
|
||||
# Secret Key для HTTP Basic Auth
|
||||
PAYPEAR_SECRET_KEY=
|
||||
PAYPEAR_DISPLAY_NAME=PayPear
|
||||
PAYPEAR_CURRENCY=RUB
|
||||
PAYPEAR_MIN_AMOUNT_KOPEKS=10000
|
||||
PAYPEAR_MAX_AMOUNT_KOPEKS=10000000
|
||||
PAYPEAR_WEBHOOK_PATH=/paypear-webhook
|
||||
# URL возврата после оплаты
|
||||
# PAYPEAR_RETURN_URL=
|
||||
# Время жизни платежа в минутах
|
||||
PAYPEAR_PAYMENT_LIFETIME_MINUTES=60
|
||||
|
||||
# ===== ROLLYPAY (rollypay.io) =====
|
||||
ROLLYPAY_ENABLED=false
|
||||
# API ключ (X-API-Key header)
|
||||
ROLLYPAY_API_KEY=
|
||||
# Секрет для HMAC-SHA256 верификации вебхуков
|
||||
ROLLYPAY_SIGNING_SECRET=
|
||||
ROLLYPAY_DISPLAY_NAME=RollyPay
|
||||
ROLLYPAY_CURRENCY=RUB
|
||||
ROLLYPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
ROLLYPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
ROLLYPAY_WEBHOOK_PATH=/rollypay-webhook
|
||||
# URL возврата после оплаты
|
||||
# ROLLYPAY_RETURN_URL=
|
||||
|
||||
# ===== AURAPAY (aurapay.tech) =====
|
||||
AURAPAY_ENABLED=false
|
||||
# API ключ (X-ApiKey header)
|
||||
AURAPAY_API_KEY=
|
||||
# UUID магазина (X-ShopId header)
|
||||
AURAPAY_SHOP_ID=
|
||||
# Секретный ключ #2 для HMAC-SHA256 верификации вебхуков
|
||||
AURAPAY_SECRET_KEY=
|
||||
AURAPAY_DISPLAY_NAME=AuraPay
|
||||
AURAPAY_CURRENCY=RUB
|
||||
AURAPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
AURAPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
AURAPAY_WEBHOOK_PATH=/aurapay-webhook
|
||||
# URL возврата после оплаты
|
||||
# AURAPAY_RETURN_URL=
|
||||
# Время жизни инвойса в минутах
|
||||
AURAPAY_PAYMENT_LIFETIME_MINUTES=60
|
||||
|
||||
# ===== WATA =====
|
||||
WATA_ENABLED=false
|
||||
WATA_BASE_URL=https://api.wata.pro
|
||||
@@ -906,6 +986,9 @@ DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
WEBHOOK_SECRET_TOKEN=
|
||||
# IP адрес сервера для setWebhook — Telegram будет использовать его напрямую без DNS резолва домена
|
||||
# Необходимо в регионах где Telegram не может резолвить домены (РФ и др.)
|
||||
# WEBHOOK_IP=
|
||||
WEBHOOK_DROP_PENDING_UPDATES=true
|
||||
WEBHOOK_MAX_QUEUE_SIZE=1024
|
||||
WEBHOOK_WORKERS=4
|
||||
@@ -946,10 +1029,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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 822 KiB |
@@ -26,36 +26,38 @@ jobs:
|
||||
- name: Get version info
|
||||
id: version
|
||||
run: |
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
||||
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
|
||||
|
||||
# Определяем версию и теги
|
||||
|
||||
# Read base version from release-please manifest (single source of truth)
|
||||
BASE_VERSION=$(jq -r '."."' .release-please-manifest.json)
|
||||
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
VERSION="v${BASE_VERSION}-${SHORT_SHA}"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
VERSION="v${BASE_VERSION}-dev-${SHORT_SHA}"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v${BASE_VERSION}-pr-${SHORT_SHA}"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-${SHORT_SHA}"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "should_push=${{ github.event_name != 'pull_request' }}" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
echo "=== Информация о сборке ==="
|
||||
echo "Версия: $VERSION"
|
||||
echo "Коммит: $(git rev-parse --short HEAD)"
|
||||
echo "Коммит: $SHORT_SHA"
|
||||
echo "Теги: $TAGS"
|
||||
echo "Push: ${{ github.event_name != 'pull_request' }}"
|
||||
echo "==========================="
|
||||
|
||||
@@ -42,25 +42,28 @@ jobs:
|
||||
- name: Get version info
|
||||
id: version
|
||||
run: |
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
||||
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
# Read base version from release-please manifest (single source of truth)
|
||||
BASE_VERSION=$(jq -r '."."' .release-please-manifest.json)
|
||||
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
VERSION="v${BASE_VERSION}-${SHORT_SHA}"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
VERSION="v${BASE_VERSION}-dev-${SHORT_SHA}"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
VERSION="v${BASE_VERSION}-pr-${SHORT_SHA}"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Определяем, нужно ли пушить образ
|
||||
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "should_push=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ PR - only build without push"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.44.0"
|
||||
".": "3.49.0"
|
||||
}
|
||||
|
||||
+139
@@ -1,5 +1,144 @@
|
||||
# Changelog
|
||||
|
||||
## [3.49.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.48.0...v3.49.0) (2026-04-18)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* integrate AuraPay payment provider ([9717936](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97179360c0288940b1fa2f6c21a6e1431a27536f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing RollyPay CRUD wrappers and guest payment flow ([0f814be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0f814be1b7dfaec84dde9acc402b2c1790417611))
|
||||
* align campaign top registrations revenue with period comparison ([16bc1d4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/16bc1d41989d66e105724ed846fb40ccf03322fd))
|
||||
* handle edge case when all tariffs are daily in legacy renewal ([29877fc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29877fc93bc612ee199ccb2438c90e57a3c1e9e0))
|
||||
* rate-limit daily subscription insufficient balance notifications to 6 hours ([ecc4a61](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ecc4a6147dad0c8886acd48f38e567a6c7fc8916))
|
||||
* redirect legacy users without tariff to tariff selection on renewal ([5986c00](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5986c00fab8c5fe2060d296afca72d28038ff7bd))
|
||||
* register PayPear and RollyPay in admin panel settings ([2aa5927](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2aa59274331610eec3cd84f90ddb49ee59da22ef))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add AuraPay to README with partner block ([25ea5c6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25ea5c60fdaf3cac9294b6df74142c176b1d4d04))
|
||||
* add PayPear and RollyPay to README with partner blocks ([1c696c6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1c696c69e34e668ff90c915249b7c3dc37bfd89b))
|
||||
* add PayPear and RollyPay to README with partner blocks ([b531959](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b53195998231d34b6e1c7165193e87fee6e5c293))
|
||||
|
||||
## [3.48.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.47.0...v3.48.0) (2026-04-16)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* integrate PayPear payment provider ([a18f6ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a18f6caa9bd9c08511c464e6141fb8aa614135b0))
|
||||
* integrate RollyPay payment provider (SBP via USDT) ([ccc2f4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ccc2f4efecf0a3c1a943971c81a9a7d1985ae14a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* increase nalogo receipt queue retry window to 12 hours ([92eaf45](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/92eaf4531162e5625b938bafc43eb98abe2632e2))
|
||||
* low balance alerts disabled by default, add quiet hours, expiry filter, top-up button ([2d5afe5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2d5afe5d75ff65f4d167a853e078943d518a881f))
|
||||
* show menu buttons for limited (traffic exhausted) subscriptions ([0c54549](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c545490b61baec930f10adc86652a7e5cf5d378))
|
||||
* show menu buttons for limited subscriptions in back-to-menu paths ([61cf495](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61cf495fc5919e699aba7231f49222722da044b4))
|
||||
* support payment_method selection for RollyPay (sbp/card/crypto) ([a598582](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a59858227f31bd53d0ac54d693288ad52e447687))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add SEVERPAY, PAYPEAR, ROLLYPAY to .env.example ([25447ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25447edc9eab7c3a76ed94be52c1b341917a40c2))
|
||||
|
||||
## [3.47.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.46.1...v3.47.0) (2026-04-15)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* multi-tariff sync fix, daily discount fix, campaign links, TELEGRAM_API_URL ([4db9e85](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4db9e850629f25cbcb11bb5ba0e0de5c580ca115))
|
||||
|
||||
## [3.46.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.46.0...v3.46.1) (2026-04-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add checkfirst guards to cabinet_refresh_tokens migration ([8587f03](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8587f03f67d7a451b07a4d9c450bc4524f4ac0e7))
|
||||
* add missing migration for cabinet_refresh_tokens table ([4707cdf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4707cdf60c9d163c1191719b3b1fc4a17ae993d2))
|
||||
* cabinet_refresh_tokens migration + notification_settings jsonb ([0274738](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02747381dce2b96af7f97b5f41d6acff5d9d8fd3))
|
||||
* change notification_settings from json to jsonb for DISTINCT compatibility ([e74fda9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e74fda954ccc63e2ac7a30933d9f9ac26772b25d))
|
||||
|
||||
## [3.46.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.2...v3.46.0) (2026-04-13)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add broadcast category (system/news/promo) + filter recipients by user prefs ([931abfe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/931abfe7a5a7fb70e9638fbf6b566fa8d1a837e4))
|
||||
* add category field to broadcast API schemas and routes ([0300044](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0300044b009f3e4b3aa3928652dfaf261a387dbc))
|
||||
* add RemnaWave retry queue for failed API calls (BUG-2, BUG-10) ([abdf296](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/abdf2967675975e90f0c4d834f129281c1c28e7b))
|
||||
* add remnawave_resync_service for identity-change sync ([b57f185](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b57f185258be050d945cec5989ad6dc710980a6a))
|
||||
* add traffic % warning check using user's threshold preference ([1d96f80](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d96f80f60ca445eb5108e7bc54e00d022a4cc9e))
|
||||
* add user notification preferences helper utility ([e0e2edf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e0e2edf81659fbeea361046d1bb2718c2149d884))
|
||||
* implement low balance alert + respect user notification preferences ([4e50419](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e50419171176ee452371ff095bdfead3879e554))
|
||||
* respect user subscription_expiry notification preferences ([63fdfe4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/63fdfe4a421942b26caca35d8bd9b1d65f1fe7e2))
|
||||
* respect user traffic_warning notification preference in webhook handler ([7208a52](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7208a52c9424d39757187fc86eec2c3460a2cdbb))
|
||||
* save campaign_slug during standalone email registration ([a8e2b62](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a8e2b62f4bb0833ca32446b34ad4e0c5615fcd2a))
|
||||
* start RemnaWave retry queue on app startup ([8f1882f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f1882f24c7d066e2d0fc756f38ce23bf082687e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add retry queue to all remaining RemnaWave error handlers ([7e920fa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7e920fa30fc8e61ed2dd31e7a09151d57b7361ca))
|
||||
* add retry queue to cabinet subscription operation RemnaWave errors ([1b376ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b376baeca120970b1ffcd406b1bbf7d9d43cee0))
|
||||
* add retry queue to classic mode bot purchase handler ([970dc54](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/970dc549dfa06ba9945d5bd861374058acdca86b))
|
||||
* add retry queue to daily subscription service RemnaWave errors ([65120f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/65120f0badc9a4581ba0a127acdae8a0b23e8501))
|
||||
* add retry queue to payment webhook and renewal service RemnaWave errors ([91a756a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91a756a33ed4ce685bdf485cdb4e91c3e08799dd))
|
||||
* add TRAFFIC_WARNING_ALERT and LOW_BALANCE_ALERT localization keys to all locales ([2321667](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2321667ecbe76bfe8dd37213e0bb4e45104e0fc5))
|
||||
* always sync squads in auto-purchase renewal (BUG-4) ([8542a39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8542a393055a93d320d5c8c6d3aa7cc291cf8def))
|
||||
* default sync_squads=True in update_remnawave_user (BUG-4) ([6aed7d3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6aed7d355bc47c4dbd2aa761d78a5e5421c32edf))
|
||||
* enforce max_attempts limit in NaloGO receipt queue ([16d9163](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/16d91638bc149c5eee8f4cdd266cbd195c411030))
|
||||
* enqueue retry on RemnaWave API failure in all purchase flows (BUG-2, BUG-10) ([9cb559f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9cb559ff3994c0f1c6ba48a4ec09dec9b391e48b))
|
||||
* exclude users with active subscriptions from expired broadcast ([1eeeb39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1eeeb39779982ebb2700cb7523760631229407da))
|
||||
* handle TelegramBadRequest when deleting old ticket notifications ([eb18b3a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb18b3a0f9ac3a617a1b21d3293e1619980a2a71))
|
||||
* match tariff_id when creating subscriptions from panel sync (BUG-11) ([646ac4c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/646ac4cfa18f738040fbc6498c5d86c1546e2b9a))
|
||||
* protect OAuth users with remnawave_uuid from sync deactivation (BUG-6) ([cf19e4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf19e4e1f7148b21d9a8072f7a0b4ae97fd04e8a))
|
||||
* raise MAX_BUTTONS_PER_ROW to 8 and allow tg:// deep links in menu editor ([570af82](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/570af82dfdec980f42be96c8f816e1677f896f81))
|
||||
* resync RemnaWave after account merge (BUG-7) ([9c08ce6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9c08ce69485b78f8fe818500e05ab6995115166a))
|
||||
* resync RemnaWave after Telegram account linking (BUG-1) ([d465ccb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d465ccb3ac3a86add6313d6bb61d53d0fe143d5e))
|
||||
* sync connected_squads from panel during sync (BUG-5) ([35412e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/35412e9f215680c0fdf1c55b5bf23496f662935c))
|
||||
* trial activation fallback to trial-eligible servers when tariff has no squads (BUG-12) + fix misleading button text ([be32010](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be32010d63966498bc6216823a75d328346d9a37))
|
||||
* upsert refresh tokens (ON CONFLICT) + periodic cleanup of expired/revoked tokens ([fb8d2b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fb8d2b3ee4566823840100b96fe2f3bc7d41edb7))
|
||||
* use 'is not None' for telegram_id in create_user API (BUG-9) ([8623521](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/862352139e8a3545e144b0618fed5011470a9a67))
|
||||
* use MAX_DEVICES_LIMIT instead of hardcoded 10 for device buttons ([bc3893b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc3893b934f0d4e5059eedbd03cdfbc628852ac1))
|
||||
* use update_remnawave_user when UUID exists in tariff_purchase (BUG-3) ([a1b6d9b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1b6d9bb619ec3de038647fe6f2e5d38979298a8))
|
||||
|
||||
## [3.45.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.1...v3.45.2) (2026-04-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* batch bug fixes from user complaints ([31adcfd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31adcfded4b161bf515d4d6b25b4395e543208f4))
|
||||
* batch bug fixes from user complaints ([78f963b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/78f963bf5e7b3439d7614584c4041f88be5beb4a))
|
||||
* исправление парсинга черного списка (поддержка '#' и извлечение username) ([357d94d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/357d94d1b0d7fc8036b00cbb6b75175c29821751))
|
||||
* исправление парсинга черного списка (поддержка '#' и извлечение username) ([2f71846](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f7184627a0fb598a8c0208905cdecf0e4bb04a7))
|
||||
|
||||
## [3.45.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.0...v3.45.1) (2026-04-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing WEBHOOK_TORRENT_DETECTED mapping + dedup before uniq… ([4165eae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4165eaea7adfdaf683b1ece16c8c93a9c4ed216d))
|
||||
* add missing WEBHOOK_TORRENT_DETECTED mapping + dedup before unique index in migration 0053 ([3b5d5a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3b5d5a18a1122ef50868fd038a09109d17795a74))
|
||||
|
||||
## [3.45.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.44.0...v3.45.0) (2026-04-03)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* send torrent blocker notification to user (not just admin) ([2f9d003](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f9d00343bee2980cc89bd24361259073b97127a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve multiple subscription bugs — LIMITED status, trial tariff blocking, traffic reset strategy, classic mode pricing, 100% discount support ([9b7ac47](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9b7ac47f16076e546da62062ff7ce18d7c308988))
|
||||
* restore missing import + rewrite user.deleted webhook to properly deactivate all subscriptions ([819f09a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/819f09a68ec95237294bae97f31c644044a3623f))
|
||||
* subscription system bugfixes + torrent notifications + user deletion cleanup ([7d24e8d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d24e8d7047c7a3a1c417e655a6fbccbe5ae577d))
|
||||
|
||||
## [3.44.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.43.1...v3.44.0) (2026-04-02)
|
||||
|
||||
|
||||
|
||||
+3
-3
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.44.0" # x-release-please-version
|
||||
ARG VERSION="v3.49.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 — полнофункциональная платформа для п
|
||||
|
||||
### 💳 Платежи
|
||||
|
||||
- 🏦 **15 платёжных провайдеров** одновременно
|
||||
- 🏦 **24+ платёжных провайдера** одновременно
|
||||
- 💰 Единый баланс: пополнение любым способом → покупка с баланса
|
||||
- ⚡ Автопокупка подписки после пополнения
|
||||
- 💾 Рекуррентные платежи (сохранённые карты)
|
||||
@@ -123,6 +123,16 @@ Bedolaga — полнофункциональная платформа для п
|
||||
| 💳 | **MulenPay** | Карты | RUB |
|
||||
| 💳 | **RioPay** | Карты | RUB |
|
||||
| 💳 | **SeverPay** | СБП, карты | RUB |
|
||||
| 🤝 | **[PayPear](https://t.me/Paymen1_Manager)** 🔸 | Карты, СБП, SberPay, T-Pay | RUB |
|
||||
| 🤝 | **[RollyPay](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)** 🔸 | СБП, карты, крипто | RUB → USDT |
|
||||
| 🤝 | **[AuraPay](https://aurapay.tech/)** 🔸 | Карты, СБП | RUB |
|
||||
| 🤝 | **[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>
|
||||
@@ -157,6 +167,81 @@ Bedolaga — официальный партнёр платёжной систе
|
||||
|
||||
📩 По вопросам: [@wyrz_wata](https://t.me/wyrz_wata)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр PayPear**
|
||||
|
||||
Bedolaga — официальный партнёр платёжной системы **[PayPear](https://paypear.ru)**.<br>
|
||||
Банковские карты, СБП, SberPay и T-Pay — всё через единый API.<br>
|
||||
Подключение по **спец. условиям** через кодовое слово **`БЕДОЛАГА`**
|
||||
|
||||
📩 Менеджер: [@Paymen1_Manager](https://t.me/Paymen1_Manager)
|
||||
|
||||
</td>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр RollyPay**
|
||||
|
||||
Bedolaga — официальный партнёр платёжного шлюза **[RollyPay](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)**.<br>
|
||||
СБП (от 5%), банковские карты РФ, крипто, вывод в USDT.<br>
|
||||
Универсальная форма оплаты, высокая проходимость, стабильная работа в каскаде.<br>
|
||||
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
|
||||
|
||||
📩 Менеджер: [@rollypay_manager](https://t.me/rollypay_manager) | 🌐 [rollypay.io](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр AuraPay**
|
||||
|
||||
Bedolaga — официальный партнёр платёжной системы **[AuraPay](https://aurapay.tech/)**.<br>
|
||||
Банковские карты и СБП через единый API с быстрой интеграцией.<br>
|
||||
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
|
||||
|
||||
📩 Менеджер: [@kickdownm](https://t.me/kickdownm) | 🌐 [aurapay.tech](https://aurapay.tech/)
|
||||
|
||||
</td>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр Overpay**
|
||||
|
||||
Bedolaga — официальный партнёр платёжного шлюза **[Overpay](https://overpay.pro/)**.<br>
|
||||
Банковские карты и СБП, mTLS-авторизация, HPP-интеграция.<br>
|
||||
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
|
||||
|
||||
📩 Менеджер: [@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>
|
||||
@@ -222,7 +307,7 @@ docker compose up -d
|
||||
| | Раздел | Описание |
|
||||
|:---:|:---|:---|
|
||||
| 🚀 | [Быстрый старт](https://docs.bedolagam.ru/getting-started/quickstart) | Развёртывание за 5 минут |
|
||||
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 14 провайдеров, webhook, фискализация |
|
||||
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 24+ провайдера, webhook, фискализация, Apple IAP |
|
||||
| 📦 | [Подписки и тарифы](https://docs.bedolagam.ru/bot/subscriptions) | Конфигурация планов и трафика |
|
||||
| 👥 | [Реферальная программа](https://docs.bedolagam.ru/bot/referral-program) | Партнёрка и вывод средств |
|
||||
| 🖥 | [Cabinet](https://docs.bedolagam.ru/cabinet/overview) | Настройка веб-кабинета |
|
||||
|
||||
+16
@@ -280,12 +280,28 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load menu layout cache', error=e)
|
||||
|
||||
try:
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
await remnawave_retry_queue.start()
|
||||
logger.info('RemnaWave retry queue запущен')
|
||||
except Exception as e:
|
||||
logger.error('Ошибка запуска RemnaWave retry queue', error=e)
|
||||
|
||||
logger.info('Бот успешно настроен')
|
||||
|
||||
return bot, dp
|
||||
|
||||
|
||||
async def shutdown_bot():
|
||||
try:
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
await remnawave_retry_queue.stop()
|
||||
logger.info('RemnaWave retry queue остановлен')
|
||||
except Exception as e:
|
||||
logger.error('Ошибка остановки RemnaWave retry queue', error=e)
|
||||
|
||||
try:
|
||||
await maintenance_service.stop_monitoring()
|
||||
logger.info('Мониторинг техработ остановлен')
|
||||
|
||||
+12
-4
@@ -1,4 +1,4 @@
|
||||
"""Factory for creating Bot instances with proxy support."""
|
||||
"""Factory for creating Bot instances with proxy and custom API server support."""
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
@@ -8,13 +8,21 @@ from app.config import settings
|
||||
|
||||
|
||||
def create_bot(token: str | None = None, **kwargs) -> Bot:
|
||||
"""Create a Bot instance with SOCKS5 proxy session if PROXY_URL is configured."""
|
||||
"""Create a Bot instance with SOCKS5 proxy and/or custom Telegram API server."""
|
||||
proxy_url = settings.get_proxy_url()
|
||||
telegram_api_url = settings.get_telegram_api_url()
|
||||
session = None
|
||||
if proxy_url:
|
||||
if proxy_url or telegram_api_url:
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from aiogram.client.telegram import TelegramAPIServer
|
||||
|
||||
session = AiohttpSession(proxy=proxy_url)
|
||||
session_kwargs: dict = {}
|
||||
if proxy_url:
|
||||
session_kwargs['proxy'] = proxy_url
|
||||
if telegram_api_url:
|
||||
session_kwargs['api'] = TelegramAPIServer.from_base(telegram_api_url)
|
||||
|
||||
session = AiohttpSession(**session_kwargs)
|
||||
|
||||
kwargs.setdefault('default', DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
return Bot(token=token or settings.BOT_TOKEN, session=session, **kwargs)
|
||||
|
||||
@@ -195,7 +195,8 @@ async def _get_jwks(force: bool = False) -> dict[str, Any]:
|
||||
if not force and _jwks_cache and _jwks_cache_expiry and now < _jwks_cache_expiry:
|
||||
return _jwks_cache
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
proxy = settings.PROXY_URL if hasattr(settings, 'PROXY_URL') and settings.PROXY_URL else None
|
||||
async with httpx.AsyncClient(timeout=10, proxy=proxy) as client:
|
||||
response = await client.get(_JWKS_URL)
|
||||
response.raise_for_status()
|
||||
_jwks_cache = response.json()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ from .admin_apps import router as admin_apps_router
|
||||
from .admin_audit_log import router as admin_audit_log_router
|
||||
from .admin_ban_system import router as admin_ban_system_router
|
||||
from .admin_broadcasts import router as admin_broadcasts_router
|
||||
from .admin_bulk_actions import router as admin_bulk_actions_router
|
||||
from .admin_button_styles import router as admin_button_styles_router
|
||||
from .admin_campaigns import router as admin_campaigns_router
|
||||
from .admin_channels import router as admin_channels_router
|
||||
from .admin_email_templates import router as admin_email_templates_router
|
||||
from .admin_info_pages import router as admin_info_pages_router
|
||||
from .admin_landings import router as admin_landings_router
|
||||
from .admin_menu_layout import router as admin_menu_layout_router
|
||||
from .admin_news import router as admin_news_router
|
||||
@@ -32,6 +34,7 @@ from .admin_servers import router as admin_servers_router
|
||||
from .admin_settings import router as admin_settings_router
|
||||
from .admin_stats import router as admin_stats_router
|
||||
from .admin_tariffs import router as admin_tariffs_router
|
||||
from .admin_tasks import router as admin_tasks_router
|
||||
from .admin_tickets import router as admin_tickets_router
|
||||
from .admin_traffic import router as admin_traffic_router
|
||||
from .admin_updates import router as admin_updates_router
|
||||
@@ -44,6 +47,7 @@ from .branding import router as branding_router
|
||||
from .contests import router as contests_router
|
||||
from .gift import router as gift_router
|
||||
from .info import router as info_router
|
||||
from .info_pages import router as info_pages_router
|
||||
from .landing import router as landing_router
|
||||
from .media import router as media_router
|
||||
from .news import router as news_router
|
||||
@@ -61,11 +65,19 @@ from .ticket_notifications import (
|
||||
router as ticket_notifications_router,
|
||||
)
|
||||
from .tickets import router as tickets_router
|
||||
from .user_tasks import router as user_tasks_router
|
||||
from .websocket import router as websocket_router
|
||||
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)
|
||||
|
||||
@@ -78,6 +90,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
|
||||
@@ -93,6 +110,8 @@ router.include_router(branding_router)
|
||||
router.include_router(landing_router)
|
||||
router.include_router(media_router)
|
||||
router.include_router(news_router)
|
||||
router.include_router(info_pages_router)
|
||||
router.include_router(user_tasks_router)
|
||||
|
||||
# Wheel routes
|
||||
router.include_router(wheel_router)
|
||||
@@ -118,6 +137,7 @@ router.include_router(admin_campaigns_router)
|
||||
router.include_router(admin_partners_router)
|
||||
router.include_router(admin_withdrawals_router)
|
||||
router.include_router(admin_users_router)
|
||||
router.include_router(admin_bulk_actions_router)
|
||||
router.include_router(admin_payment_methods_router)
|
||||
router.include_router(admin_landings_router)
|
||||
router.include_router(admin_payments_router)
|
||||
@@ -140,6 +160,8 @@ router.include_router(admin_news_categories_router)
|
||||
router.include_router(admin_news_tags_router)
|
||||
router.include_router(admin_news_media_router)
|
||||
router.include_router(admin_news_router)
|
||||
router.include_router(admin_info_pages_router)
|
||||
router.include_router(admin_tasks_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
@@ -622,6 +622,24 @@ async def link_telegram(
|
||||
telegram_id=telegram_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
# BUG-1 fix: Sync all subscriptions with RemnaWave panel so it knows the new telegram_id
|
||||
try:
|
||||
from app.services.remnawave_resync_service import resync_user_subscriptions_with_panel
|
||||
|
||||
resync_result = await resync_user_subscriptions_with_panel(db, user)
|
||||
logger.info(
|
||||
'Post-TG-link resync completed',
|
||||
user_id=user.id,
|
||||
telegram_id=telegram_id,
|
||||
synced=resync_result['synced'],
|
||||
failed=resync_result['failed'],
|
||||
)
|
||||
except Exception as resync_error:
|
||||
logger.error(
|
||||
'Post-TG-link resync failed (non-fatal)',
|
||||
user_id=user.id,
|
||||
error=resync_error,
|
||||
)
|
||||
return LinkCallbackResponse(success=True, message='linked')
|
||||
|
||||
|
||||
@@ -867,6 +885,25 @@ async def execute_merge_endpoint(
|
||||
detail='Failed to load merged user',
|
||||
)
|
||||
|
||||
# BUG-7 fix: Resync merged user's subscriptions with RemnaWave panel
|
||||
try:
|
||||
from app.services.remnawave_resync_service import resync_user_subscriptions_with_panel
|
||||
|
||||
resync_result = await resync_user_subscriptions_with_panel(db, merged_user)
|
||||
logger.info(
|
||||
'Post-merge resync completed',
|
||||
primary_user_id=primary_user_id,
|
||||
secondary_user_id=secondary_user_id,
|
||||
synced=resync_result['synced'],
|
||||
failed=resync_result['failed'],
|
||||
)
|
||||
except Exception as resync_error:
|
||||
logger.error(
|
||||
'Post-merge resync failed (non-fatal)',
|
||||
primary_user_id=primary_user_id,
|
||||
error=resync_error,
|
||||
)
|
||||
|
||||
# 5. Create auth tokens for the merged user
|
||||
try:
|
||||
auth_response = await _create_auth_response(merged_user, db)
|
||||
|
||||
@@ -141,6 +141,7 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
|
||||
created_at=broadcast.created_at,
|
||||
completed_at=broadcast.completed_at,
|
||||
progress_percent=progress,
|
||||
category=getattr(broadcast, 'category', 'system') or 'system',
|
||||
channel=getattr(broadcast, 'channel', 'telegram') or 'telegram',
|
||||
email_subject=getattr(broadcast, 'email_subject', None),
|
||||
email_html_content=getattr(broadcast, 'email_html_content', None),
|
||||
@@ -432,6 +433,7 @@ async def create_broadcast(
|
||||
status='queued',
|
||||
admin_id=admin.id,
|
||||
admin_name=admin.username or f'Admin #{admin.id}',
|
||||
category=request.category,
|
||||
)
|
||||
db.add(broadcast)
|
||||
await db.commit()
|
||||
@@ -454,6 +456,7 @@ async def create_broadcast(
|
||||
media=media_config,
|
||||
initiator_name=admin.username or f'Admin #{admin.id}',
|
||||
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
|
||||
category=request.category,
|
||||
)
|
||||
|
||||
# Start broadcast
|
||||
@@ -626,6 +629,7 @@ async def create_combined_broadcast(
|
||||
status='queued',
|
||||
admin_id=admin.id,
|
||||
admin_name=admin_name,
|
||||
category=request.category,
|
||||
channel=request.channel,
|
||||
email_subject=request.email_subject.strip() if request.email_subject else None,
|
||||
email_html_content=request.email_html_content.strip() if request.email_html_content else None,
|
||||
@@ -653,6 +657,7 @@ async def create_combined_broadcast(
|
||||
media=media_config,
|
||||
initiator_name=admin_name,
|
||||
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
|
||||
category=request.category,
|
||||
)
|
||||
|
||||
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
"""Admin routes for managing info pages in cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.info_pages import (
|
||||
clear_replaces_tab,
|
||||
create_info_page,
|
||||
delete_info_page,
|
||||
get_all_info_pages,
|
||||
get_info_page_by_id,
|
||||
reorder_info_pages,
|
||||
update_info_page,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.info_pages import (
|
||||
InfoPageCreateRequest,
|
||||
InfoPageListItem,
|
||||
InfoPageResponse,
|
||||
InfoPageUpdateRequest,
|
||||
ReorderRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
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('info_pages:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[InfoPageListItem]:
|
||||
"""Get all info pages (admin view, includes inactive)."""
|
||||
try:
|
||||
pages = await get_all_info_pages(db, include_inactive=True, page_type=page_type)
|
||||
return [InfoPageListItem.model_validate(p) for p in pages]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Failed to list info pages')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load info pages',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{page_id}', response_model=InfoPageResponse)
|
||||
async def get_info_page_detail(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('info_pages:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Get a single info page by ID (admin view)."""
|
||||
page = await get_info_page_by_id(db, page_id)
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
return InfoPageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.post('', response_model=InfoPageResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_page(
|
||||
request: InfoPageCreateRequest,
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Create a new info page."""
|
||||
try:
|
||||
if request.replaces_tab:
|
||||
await clear_replaces_tab(db, request.replaces_tab)
|
||||
|
||||
page = await create_info_page(
|
||||
db,
|
||||
slug=request.slug,
|
||||
title=request.title,
|
||||
content=request.content,
|
||||
page_type=request.page_type,
|
||||
is_active=request.is_active,
|
||||
sort_order=request.sort_order,
|
||||
icon=request.icon,
|
||||
replaces_tab=request.replaces_tab,
|
||||
)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An info page with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create info page')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create info page',
|
||||
)
|
||||
|
||||
return InfoPageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.put('/{page_id}', response_model=InfoPageResponse)
|
||||
async def update_page(
|
||||
page_id: int,
|
||||
request: InfoPageUpdateRequest,
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Update an existing info page."""
|
||||
existing = await get_info_page_by_id(db, page_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
try:
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
|
||||
replaces_tab = update_data.get('replaces_tab')
|
||||
if replaces_tab is not None:
|
||||
await clear_replaces_tab(db, replaces_tab, exclude_page_id=page_id)
|
||||
|
||||
page = await update_info_page(db, page_id, **update_data)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An info page with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to update info page', page_id=page_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update info page',
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found after update',
|
||||
)
|
||||
return InfoPageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.delete('/{page_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_page(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete an info page."""
|
||||
existing = await get_info_page_by_id(db, page_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
try:
|
||||
await delete_info_page(db, page_id)
|
||||
except Exception:
|
||||
logger.exception('Failed to delete info page', page_id=page_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to delete info page',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/reorder', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def reorder_pages(
|
||||
request: ReorderRequest,
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Bulk update sort_order for info pages."""
|
||||
try:
|
||||
await reorder_info_pages(db, request.items)
|
||||
except Exception:
|
||||
logger.exception('Failed to reorder info pages')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reorder info pages',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{page_id}/toggle-active', response_model=InfoPageResponse)
|
||||
async def toggle_active(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Toggle the active status of an info page."""
|
||||
existing = await get_info_page_by_id(db, page_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
try:
|
||||
page = await update_info_page(db, page_id, is_active=not existing.is_active)
|
||||
except Exception:
|
||||
logger.exception('Failed to toggle info page active status', page_id=page_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to toggle active status',
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found after toggle',
|
||||
)
|
||||
return InfoPageResponse.model_validate(page)
|
||||
@@ -205,6 +205,19 @@ class LandingCreateRequest(BaseModel):
|
||||
discount_ends_at: datetime | None = None
|
||||
discount_badge_text: dict[str, str] | None = None
|
||||
background_config: dict | None = None
|
||||
sticky_pay_button: bool = False
|
||||
analytics_view_enabled: bool = False
|
||||
analytics_view_goal: str | None = Field(default=None, max_length=64)
|
||||
analytics_click_enabled: bool = False
|
||||
analytics_click_goal: str | None = Field(default=None, max_length=64)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_analytics_goals(self) -> 'LandingCreateRequest':
|
||||
if self.analytics_view_enabled and not self.analytics_view_goal:
|
||||
raise ValueError('analytics_view_goal is required when analytics_view_enabled is True')
|
||||
if self.analytics_click_enabled and not self.analytics_click_goal:
|
||||
raise ValueError('analytics_click_goal is required when analytics_click_enabled is True')
|
||||
return self
|
||||
|
||||
@field_validator('background_config')
|
||||
@classmethod
|
||||
@@ -314,6 +327,11 @@ class LandingUpdateRequest(BaseModel):
|
||||
discount_ends_at: datetime | None = None
|
||||
discount_badge_text: dict[str, str] | None = None
|
||||
background_config: dict | None = None
|
||||
sticky_pay_button: bool | None = None
|
||||
analytics_view_enabled: bool | None = None
|
||||
analytics_view_goal: str | None = Field(default=None, max_length=64)
|
||||
analytics_click_enabled: bool | None = None
|
||||
analytics_click_goal: str | None = Field(default=None, max_length=64)
|
||||
|
||||
@field_validator('background_config')
|
||||
@classmethod
|
||||
@@ -461,6 +479,11 @@ class LandingDetailResponse(BaseModel):
|
||||
discount_ends_at: datetime | None = None
|
||||
discount_badge_text: dict[str, str] | None = None
|
||||
background_config: dict | None = None
|
||||
sticky_pay_button: bool = False
|
||||
analytics_view_enabled: bool = False
|
||||
analytics_view_goal: str | None = None
|
||||
analytics_click_enabled: bool = False
|
||||
analytics_click_goal: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -527,6 +550,7 @@ class LandingPurchaseItem(BaseModel):
|
||||
currency: str
|
||||
payment_method: str | None = None
|
||||
status: str
|
||||
referrer: str | None = None
|
||||
created_at: datetime | None = None
|
||||
paid_at: datetime | None = None
|
||||
|
||||
@@ -638,6 +662,11 @@ async def create_landing_page(
|
||||
discount_ends_at=request.discount_ends_at,
|
||||
discount_badge_text=request.discount_badge_text,
|
||||
background_config=request.background_config,
|
||||
sticky_pay_button=request.sticky_pay_button,
|
||||
analytics_view_enabled=request.analytics_view_enabled,
|
||||
analytics_view_goal=request.analytics_view_goal,
|
||||
analytics_click_enabled=request.analytics_click_enabled,
|
||||
analytics_click_goal=request.analytics_click_goal,
|
||||
)
|
||||
|
||||
logger.info('Admin created landing page', admin_id=admin.id, slug=landing.slug, landing_id=landing.id)
|
||||
@@ -979,6 +1008,7 @@ async def get_landing_purchases(
|
||||
GuestPurchase.currency,
|
||||
GuestPurchase.payment_method,
|
||||
GuestPurchase.status,
|
||||
GuestPurchase.referrer,
|
||||
GuestPurchase.created_at,
|
||||
GuestPurchase.paid_at,
|
||||
)
|
||||
@@ -1004,6 +1034,7 @@ async def get_landing_purchases(
|
||||
currency=row.currency,
|
||||
payment_method=row.payment_method,
|
||||
status=row.status,
|
||||
referrer=row.referrer,
|
||||
created_at=row.created_at,
|
||||
paid_at=row.paid_at,
|
||||
)
|
||||
@@ -1068,6 +1099,11 @@ def _landing_to_detail(landing: LandingPage) -> LandingDetailResponse:
|
||||
discount_ends_at=landing.discount_ends_at,
|
||||
discount_badge_text=landing.discount_badge_text,
|
||||
background_config=landing.background_config,
|
||||
sticky_pay_button=landing.sticky_pay_button,
|
||||
analytics_view_enabled=landing.analytics_view_enabled,
|
||||
analytics_view_goal=landing.analytics_view_goal,
|
||||
analytics_click_enabled=landing.analytics_click_enabled,
|
||||
analytics_click_goal=landing.analytics_click_goal,
|
||||
created_at=landing.created_at,
|
||||
updated_at=landing.updated_at,
|
||||
)
|
||||
|
||||
@@ -42,9 +42,9 @@ router = APIRouter(prefix='/admin/menu-layout', tags=['Admin Menu Layout'])
|
||||
# ---- Constants ---------------------------------------------------------------
|
||||
|
||||
MAX_ROWS = 20
|
||||
MAX_BUTTONS_PER_ROW = 3
|
||||
MAX_BUTTONS_PER_ROW = 8 # Telegram inline keyboard limit
|
||||
MAX_LABEL_LENGTH = 100
|
||||
URL_PATTERN = re.compile(r'^https?://')
|
||||
URL_PATTERN = re.compile(r'^(https?://|tg://)')
|
||||
|
||||
|
||||
# ---- Schemas -----------------------------------------------------------------
|
||||
@@ -275,7 +275,7 @@ def _validate_update_payload(rows: list[RowConfig]) -> None:
|
||||
if not btn.url or not URL_PATTERN.match(btn.url):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Custom button "{btn.id}" must have a URL starting with http:// or https://.',
|
||||
detail=f'Custom button "{btn.id}" must have a URL starting with http://, https://, or tg://.',
|
||||
)
|
||||
if btn.open_in == 'webapp' and not btn.url.startswith('https://'):
|
||||
raise HTTPException(
|
||||
|
||||
@@ -4,14 +4,14 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
@@ -128,6 +128,20 @@ class PromoOfferBroadcastRequest(BaseModel):
|
||||
message_text: str | None = Field(None, description='Custom message text (HTML)')
|
||||
button_text: str | None = Field(None, description='Button text')
|
||||
|
||||
_TARGET_ALIASES: ClassVar[dict[str, str]] = {
|
||||
'no_sub': 'no',
|
||||
'all_users': 'all',
|
||||
'active_subscribers': 'active',
|
||||
'trial_users': 'trial',
|
||||
}
|
||||
|
||||
@validator('target')
|
||||
def normalize_target(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip().lower()
|
||||
return cls._TARGET_ALIASES.get(normalized, normalized)
|
||||
|
||||
|
||||
class PromoOfferBroadcastResponse(BaseModel):
|
||||
created_offers: int
|
||||
|
||||
@@ -273,6 +273,8 @@ async def get_tariff(
|
||||
external_squad_uuid=tariff.external_squad_uuid,
|
||||
# Показывать в подарках
|
||||
show_in_gift=tariff.show_in_gift,
|
||||
# Бонусные дни Tasks
|
||||
bonus_days_per_purchase=getattr(tariff, 'bonus_days_per_purchase', 0) or 0,
|
||||
created_at=tariff.created_at,
|
||||
updated_at=tariff.updated_at,
|
||||
)
|
||||
@@ -331,6 +333,8 @@ async def create_new_tariff(
|
||||
external_squad_uuid=request.external_squad_uuid,
|
||||
# Показывать в подарках
|
||||
show_in_gift=request.show_in_gift,
|
||||
# Бонусные дни Tasks
|
||||
bonus_days_per_purchase=request.bonus_days_per_purchase,
|
||||
)
|
||||
|
||||
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
|
||||
@@ -430,6 +434,9 @@ async def update_existing_tariff(
|
||||
# Показывать в подарках
|
||||
if request.show_in_gift is not None:
|
||||
updates['show_in_gift'] = request.show_in_gift
|
||||
# Бонусные дни Tasks
|
||||
if request.bonus_days_per_purchase is not None:
|
||||
updates['bonus_days_per_purchase'] = request.bonus_days_per_purchase
|
||||
|
||||
if updates:
|
||||
await update_tariff(db, tariff, **updates)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Admin endpoints для системы заданий с наградами и партнёрских каналов."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.cabinet.dependencies import get_cabinet_db, require_permission
|
||||
from app.cabinet.schemas.tasks import (
|
||||
TaskCreateRequest,
|
||||
TaskListItem,
|
||||
TaskPartnerChannelCreateRequest,
|
||||
TaskPartnerChannelResponse,
|
||||
TaskPartnerChannelUpdateRequest,
|
||||
TaskResponse,
|
||||
TaskUpdateRequest,
|
||||
)
|
||||
from app.database.crud import tasks as tasks_crud
|
||||
from app.database.models import User
|
||||
|
||||
|
||||
router = APIRouter(prefix='/admin', tags=['Cabinet Admin Tasks'])
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Tasks
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@router.get('/tasks', response_model=list[TaskListItem])
|
||||
async def admin_list_tasks(
|
||||
include_inactive: bool = True,
|
||||
parent_task_id: int | None = None,
|
||||
admin: User = Depends(require_permission('tasks:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
tasks = await tasks_crud.list_tasks(db, include_inactive=include_inactive, parent_task_id=parent_task_id)
|
||||
return [TaskListItem.model_validate(t) for t in tasks]
|
||||
|
||||
|
||||
@router.get('/tasks/{task_id}', response_model=TaskResponse)
|
||||
async def admin_get_task(
|
||||
task_id: int,
|
||||
admin: User = Depends(require_permission('tasks:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
task = await tasks_crud.get_task_by_id(db, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='task_not_found')
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
|
||||
@router.post('/tasks', response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def admin_create_task(
|
||||
request: TaskCreateRequest,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
if request.parent_task_id is not None:
|
||||
parent = await tasks_crud.get_task_by_id(db, request.parent_task_id)
|
||||
if parent is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='parent_task_not_found')
|
||||
|
||||
task = await tasks_crud.create_task(
|
||||
db,
|
||||
title=request.title,
|
||||
description=request.description,
|
||||
task_type=request.task_type,
|
||||
reward_type=request.reward_type,
|
||||
target_value=request.target_value,
|
||||
reward_value=request.reward_value,
|
||||
target_meta=request.target_meta,
|
||||
reward_meta=request.reward_meta,
|
||||
icon=request.icon,
|
||||
is_active=request.is_active,
|
||||
sort_order=request.sort_order,
|
||||
allow_user_choice=request.allow_user_choice,
|
||||
user_audience=request.user_audience,
|
||||
promo_group_id=request.promo_group_id,
|
||||
parent_task_id=request.parent_task_id,
|
||||
level=request.level,
|
||||
starts_at=request.starts_at,
|
||||
ends_at=request.ends_at,
|
||||
)
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
|
||||
async def _parent_chain_has_cycle(db: AsyncSession, *, task_id: int, parent_id: int, max_depth: int = 10) -> bool:
|
||||
"""Идёт вверх по цепочке parent — проверяет, не возвращается ли в task_id."""
|
||||
current = parent_id
|
||||
visited: set[int] = set()
|
||||
for _ in range(max_depth):
|
||||
if current == task_id:
|
||||
return True
|
||||
if current in visited:
|
||||
return False
|
||||
visited.add(current)
|
||||
parent = await tasks_crud.get_task_by_id(db, current)
|
||||
if parent is None or parent.parent_task_id is None:
|
||||
return False
|
||||
current = parent.parent_task_id
|
||||
return False # max_depth достигнут — дальше не считаем циклом
|
||||
|
||||
|
||||
@router.put('/tasks/{task_id}', response_model=TaskResponse)
|
||||
async def admin_update_task(
|
||||
task_id: int,
|
||||
request: TaskUpdateRequest,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
task = await tasks_crud.get_task_by_id(db, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='task_not_found')
|
||||
|
||||
if request.parent_task_id is not None and request.parent_task_id == task_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='parent_task_cannot_be_self')
|
||||
|
||||
if request.parent_task_id is not None:
|
||||
if await _parent_chain_has_cycle(db, task_id=task_id, parent_id=request.parent_task_id):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='parent_chain_cycle_detected')
|
||||
|
||||
fields = request.model_dump(exclude_unset=True)
|
||||
if not fields:
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
updated = await tasks_crud.update_task(db, task, **fields)
|
||||
return TaskResponse.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete('/tasks/{task_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def admin_delete_task(
|
||||
task_id: int,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
task = await tasks_crud.get_task_by_id(db, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='task_not_found')
|
||||
await tasks_crud.delete_task(db, task)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Partner channels
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@router.get('/task-partner-channels', response_model=list[TaskPartnerChannelResponse])
|
||||
async def admin_list_partner_channels(
|
||||
include_inactive: bool = True,
|
||||
admin: User = Depends(require_permission('tasks:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
channels = await tasks_crud.list_partner_channels(db, include_inactive=include_inactive)
|
||||
return [TaskPartnerChannelResponse.model_validate(c) for c in channels]
|
||||
|
||||
|
||||
@router.post(
|
||||
'/task-partner-channels',
|
||||
response_model=TaskPartnerChannelResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def admin_create_partner_channel(
|
||||
request: TaskPartnerChannelCreateRequest,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
existing = await tasks_crud.get_partner_channel_by_channel_id(db, request.channel_id)
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='channel_id_already_exists')
|
||||
channel = await tasks_crud.create_partner_channel(
|
||||
db,
|
||||
channel_id=request.channel_id,
|
||||
title=request.title,
|
||||
channel_link=request.channel_link,
|
||||
description=request.description,
|
||||
is_active=request.is_active,
|
||||
sort_order=request.sort_order,
|
||||
)
|
||||
return TaskPartnerChannelResponse.model_validate(channel)
|
||||
|
||||
|
||||
@router.put('/task-partner-channels/{channel_pk}', response_model=TaskPartnerChannelResponse)
|
||||
async def admin_update_partner_channel(
|
||||
channel_pk: int,
|
||||
request: TaskPartnerChannelUpdateRequest,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
channel = await tasks_crud.get_partner_channel_by_id(db, channel_pk)
|
||||
if channel is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='channel_not_found')
|
||||
fields = request.model_dump(exclude_unset=True)
|
||||
updated = await tasks_crud.update_partner_channel(db, channel, **fields)
|
||||
return TaskPartnerChannelResponse.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete('/task-partner-channels/{channel_pk}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def admin_delete_partner_channel(
|
||||
channel_pk: int,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
channel = await tasks_crud.get_partner_channel_by_id(db, channel_pk)
|
||||
if channel is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='channel_not_found')
|
||||
await tasks_crud.delete_partner_channel(db, channel)
|
||||
@@ -17,7 +17,7 @@ from app.database.crud.ticket_notification import TicketNotificationCRUD
|
||||
from app.database.models import Ticket, TicketMessage, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.tickets import TicketMessageResponse
|
||||
from ..schemas.tickets import TicketMediaItem, TicketMessageResponse, _validate_media_bundle
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -89,19 +89,19 @@ class AdminTicketListResponse(BaseModel):
|
||||
class AdminReplyRequest(BaseModel):
|
||||
"""Admin reply to ticket."""
|
||||
|
||||
message: str = Field(..., min_length=1, max_length=4000, description='Reply message')
|
||||
message: str = Field(default='', max_length=4000, description='Reply message')
|
||||
media_type: str | None = Field(None, description='Media type: photo, video, or document')
|
||||
media_file_id: str | None = Field(None, max_length=255, description='Telegram file_id from media upload')
|
||||
media_caption: str | None = Field(None, max_length=1000, description='Caption for media')
|
||||
media_items: list[TicketMediaItem] | None = Field(None, description='Multi-media gallery attachments')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_media_fields(self) -> 'AdminReplyRequest':
|
||||
if self.media_file_id and not self.media_type:
|
||||
raise ValueError('media_type is required when media_file_id is provided')
|
||||
if self.media_type and not self.media_file_id:
|
||||
raise ValueError('media_file_id is required when media_type is provided')
|
||||
if self.media_type and self.media_type not in {'photo', 'video', 'document'}:
|
||||
raise ValueError('media_type must be one of: photo, video, document')
|
||||
_validate_media_bundle(self.media_type, self.media_file_id, self.media_items)
|
||||
has_text = bool(self.message.strip())
|
||||
has_media = bool(self.media_file_id) or bool(self.media_items)
|
||||
if not has_text and not has_media:
|
||||
raise ValueError('message or media is required')
|
||||
return self
|
||||
|
||||
|
||||
@@ -157,14 +157,23 @@ class TicketSettingsUpdateRequest(BaseModel):
|
||||
|
||||
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
|
||||
"""Convert TicketMessage to response."""
|
||||
raw_items = getattr(message, 'media_items', None) or None
|
||||
items = None
|
||||
if raw_items:
|
||||
try:
|
||||
items = [TicketMediaItem(**it) for it in raw_items]
|
||||
except (TypeError, KeyError, ValueError) as exc:
|
||||
logger.warning('Failed to parse media_items', message_id=message.id, error=str(exc))
|
||||
items = None
|
||||
return TicketMessageResponse(
|
||||
id=message.id,
|
||||
message_text=message.message_text or '',
|
||||
is_from_admin=message.is_from_admin,
|
||||
has_media=bool(message.media_file_id),
|
||||
has_media=bool(message.media_file_id) or bool(items),
|
||||
media_type=message.media_type,
|
||||
media_file_id=message.media_file_id,
|
||||
media_caption=message.media_caption,
|
||||
media_items=items,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
@@ -455,17 +464,29 @@ async def reply_to_ticket(
|
||||
detail='Ticket not found',
|
||||
)
|
||||
|
||||
# Create admin message
|
||||
has_media = bool(request.media_file_id)
|
||||
# Resolve media payload: prefer media_items, fall back to legacy single-media fields
|
||||
items_payload = None
|
||||
primary_type = request.media_type
|
||||
primary_file_id = request.media_file_id
|
||||
primary_caption = request.media_caption
|
||||
if request.media_items:
|
||||
items_payload = [it.model_dump() for it in request.media_items]
|
||||
first = request.media_items[0]
|
||||
primary_type = first.type
|
||||
primary_file_id = first.file_id
|
||||
primary_caption = primary_caption or first.caption
|
||||
has_media = bool(primary_file_id)
|
||||
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=ticket.user_id,
|
||||
message_text=request.message,
|
||||
is_from_admin=True,
|
||||
has_media=has_media,
|
||||
media_type=request.media_type if has_media else None,
|
||||
media_file_id=request.media_file_id if has_media else None,
|
||||
media_caption=request.media_caption if has_media else None,
|
||||
media_type=primary_type if has_media else None,
|
||||
media_file_id=primary_file_id if has_media else None,
|
||||
media_caption=primary_caption if has_media else None,
|
||||
media_items=items_payload,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Admin routes for managing users in cabinet."""
|
||||
|
||||
import math
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
@@ -70,6 +71,7 @@ from ..schemas.users import (
|
||||
ResetTrialRequest,
|
||||
ResetTrialResponse,
|
||||
SortByEnum,
|
||||
SubscriptionListItem,
|
||||
SyncFromPanelRequest,
|
||||
SyncFromPanelResponse,
|
||||
SyncToPanelRequest,
|
||||
@@ -119,6 +121,12 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt
|
||||
subscription_is_trial = False
|
||||
subscription_end_date = None
|
||||
has_subscription = False
|
||||
tariff_id = None
|
||||
tariff_name = None
|
||||
traffic_used_gb = 0.0
|
||||
traffic_limit_gb = 0
|
||||
device_limit = 0
|
||||
days_remaining = 0
|
||||
|
||||
subs = getattr(user, 'subscriptions', None) or []
|
||||
subscription = next((s for s in subs if s.is_active), subs[0] if subs else None)
|
||||
@@ -127,6 +135,37 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt
|
||||
subscription_status = subscription.status
|
||||
subscription_is_trial = subscription.is_trial
|
||||
subscription_end_date = subscription.end_date
|
||||
tariff_id = subscription.tariff_id
|
||||
tariff_name = subscription.tariff.name if subscription.tariff else None
|
||||
traffic_used_gb = subscription.traffic_used_gb or 0.0
|
||||
traffic_limit_gb = subscription.traffic_limit_gb or 0
|
||||
device_limit = subscription.device_limit or 0
|
||||
if subscription.end_date:
|
||||
delta = subscription.end_date - datetime.now(UTC)
|
||||
days_remaining = max(0, delta.days)
|
||||
|
||||
# Build per-subscription list (always — bulk actions need it for any mode)
|
||||
sub_list: list[SubscriptionListItem] = []
|
||||
if subs:
|
||||
for s in subs:
|
||||
s_days = 0
|
||||
if s.end_date:
|
||||
s_delta = s.end_date - datetime.now(UTC)
|
||||
s_days = max(0, s_delta.days)
|
||||
sub_list.append(
|
||||
SubscriptionListItem(
|
||||
id=s.id,
|
||||
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,
|
||||
traffic_limit_gb=s.traffic_limit_gb or 0,
|
||||
device_limit=s.device_limit or 0,
|
||||
)
|
||||
)
|
||||
|
||||
return UserListItem(
|
||||
id=user.id,
|
||||
@@ -144,6 +183,13 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt
|
||||
subscription_status=subscription_status,
|
||||
subscription_is_trial=subscription_is_trial,
|
||||
subscription_end_date=subscription_end_date,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff_name,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
days_remaining=days_remaining,
|
||||
subscriptions=sub_list,
|
||||
promo_group_id=user.promo_group_id,
|
||||
promo_group_name=user.promo_group.name if user.promo_group else None,
|
||||
total_spent_kopeks=user_stats.get('total_spent', 0),
|
||||
@@ -236,8 +282,9 @@ async def _sync_subscription_to_panel(
|
||||
"""
|
||||
try:
|
||||
from app.config import settings
|
||||
from app.external.remnawave_api import TrafficLimitStrategy, UserStatus as PanelUserStatus
|
||||
from app.external.remnawave_api import UserStatus as PanelUserStatus
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import get_traffic_reset_strategy
|
||||
from app.utils.subscription_utils import resolve_hwid_device_limit_for_payload
|
||||
|
||||
service = RemnaWaveService()
|
||||
@@ -323,7 +370,7 @@ async def _sync_subscription_to_panel(
|
||||
'uuid': panel_uuid,
|
||||
'status': panel_status,
|
||||
'traffic_limit_bytes': traffic_limit_bytes,
|
||||
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
|
||||
'traffic_limit_strategy': get_traffic_reset_strategy(subscription.tariff),
|
||||
'description': description,
|
||||
}
|
||||
if expire_at:
|
||||
@@ -346,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
|
||||
@@ -358,7 +408,7 @@ async def _sync_subscription_to_panel(
|
||||
'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)),
|
||||
'status': panel_status,
|
||||
'traffic_limit_bytes': traffic_limit_bytes,
|
||||
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
|
||||
'traffic_limit_strategy': get_traffic_reset_strategy(subscription.tariff),
|
||||
'telegram_id': user.telegram_id,
|
||||
'email': user.email,
|
||||
'description': description,
|
||||
@@ -416,6 +466,11 @@ async def list_users(
|
||||
search: str | None = Query(None, max_length=255),
|
||||
email: str | None = Query(None, max_length=255),
|
||||
status: UserStatusEnum | None = Query(None),
|
||||
subscription_status: str | None = Query(None, max_length=20),
|
||||
tariff_id: str | None = Query(None, max_length=255),
|
||||
promo_group_id: int | None = Query(None),
|
||||
campaign_id: int | None = Query(None),
|
||||
partner_id: int | None = Query(None),
|
||||
sort_by: SortByEnum = Query(SortByEnum.CREATED_AT),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -442,6 +497,14 @@ async def list_users(
|
||||
order_by_total_spent = sort_by == SortByEnum.TOTAL_SPENT
|
||||
order_by_purchase_count = sort_by == SortByEnum.PURCHASE_COUNT
|
||||
|
||||
# Parse comma-separated tariff_ids
|
||||
tariff_ids: list[int] | None = None
|
||||
if tariff_id:
|
||||
try:
|
||||
tariff_ids = [int(x.strip()) for x in tariff_id.split(',') if x.strip()]
|
||||
except ValueError:
|
||||
tariff_ids = None
|
||||
|
||||
users = await get_users_list(
|
||||
db=db,
|
||||
offset=offset,
|
||||
@@ -449,6 +512,11 @@ async def list_users(
|
||||
search=search,
|
||||
email=email,
|
||||
status=user_status,
|
||||
subscription_status=subscription_status,
|
||||
tariff_ids=tariff_ids,
|
||||
promo_group_id=promo_group_id,
|
||||
campaign_id=campaign_id,
|
||||
partner_id=partner_id,
|
||||
order_by_balance=order_by_balance,
|
||||
order_by_traffic=order_by_traffic,
|
||||
order_by_last_activity=order_by_last_activity,
|
||||
@@ -456,7 +524,17 @@ async def list_users(
|
||||
order_by_purchase_count=order_by_purchase_count,
|
||||
)
|
||||
|
||||
total = await get_users_count(db=db, status=user_status, search=search, email=email)
|
||||
total = await get_users_count(
|
||||
db=db,
|
||||
status=user_status,
|
||||
search=search,
|
||||
email=email,
|
||||
subscription_status=subscription_status,
|
||||
tariff_ids=tariff_ids,
|
||||
promo_group_id=promo_group_id,
|
||||
campaign_id=campaign_id,
|
||||
partner_id=partner_id,
|
||||
)
|
||||
|
||||
# Get spending stats for all users
|
||||
user_ids = [u.id for u in users]
|
||||
@@ -822,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,
|
||||
@@ -1049,6 +1171,17 @@ async def update_user_subscription(
|
||||
detail='User already has a subscription. Enable multi-tariff mode to add more.',
|
||||
)
|
||||
|
||||
# Проверка: нельзя создать вторую активную подписку с тем же тарифом
|
||||
if is_multi_tariff and request.tariff_id:
|
||||
from app.database.crud.subscription import get_subscription_by_user_and_tariff
|
||||
|
||||
existing = await get_subscription_by_user_and_tariff(db, user.id, request.tariff_id)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='User already has an active subscription for this tariff. Extend it instead.',
|
||||
)
|
||||
|
||||
from app.database.crud.subscription import create_paid_subscription
|
||||
|
||||
days = request.days or 30
|
||||
@@ -1068,16 +1201,25 @@ async def update_user_subscription(
|
||||
if tariff.allowed_squads:
|
||||
connected_squads = tariff.allowed_squads
|
||||
|
||||
new_sub = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=days,
|
||||
traffic_limit_gb=traffic_limit,
|
||||
device_limit=device_limit,
|
||||
is_trial=is_trial,
|
||||
tariff_id=request.tariff_id,
|
||||
connected_squads=connected_squads,
|
||||
)
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
try:
|
||||
new_sub = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=days,
|
||||
traffic_limit_gb=traffic_limit,
|
||||
device_limit=device_limit,
|
||||
is_trial=is_trial,
|
||||
tariff_id=request.tariff_id,
|
||||
connected_squads=connected_squads,
|
||||
)
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='User already has an active subscription for this tariff. Extend it instead.',
|
||||
)
|
||||
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, new_sub)
|
||||
@@ -1190,6 +1332,18 @@ async def update_user_subscription(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Проверка: нельзя сменить тариф, если у пользователя уже есть
|
||||
# другая активная подписка с целевым тарифом
|
||||
if is_multi_tariff and request.tariff_id != subscription.tariff_id:
|
||||
from app.database.crud.subscription import get_subscription_by_user_and_tariff
|
||||
|
||||
existing = await get_subscription_by_user_and_tariff(db, user.id, request.tariff_id)
|
||||
if existing and existing.id != subscription.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='User already has an active subscription for the target tariff',
|
||||
)
|
||||
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
@@ -1321,6 +1475,18 @@ async def update_user_subscription(
|
||||
)
|
||||
|
||||
if request.action == 'activate':
|
||||
# Проверка: нельзя активировать, если у пользователя уже есть
|
||||
# другая активная подписка с тем же тарифом
|
||||
if is_multi_tariff and subscription.tariff_id:
|
||||
from app.database.crud.subscription import get_subscription_by_user_and_tariff
|
||||
|
||||
existing = await get_subscription_by_user_and_tariff(db, user.id, subscription.tariff_id)
|
||||
if existing and existing.id != subscription.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Cannot activate: user already has an active subscription for this tariff',
|
||||
)
|
||||
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
|
||||
# Extend by 30 days if expired
|
||||
@@ -1626,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)
|
||||
@@ -1637,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 ===
|
||||
@@ -3022,6 +3215,11 @@ async def sync_user_from_panel(
|
||||
changes['remnawave_short_uuid'] = {'old': sub.remnawave_short_uuid, 'new': panel_user.short_uuid}
|
||||
sub.remnawave_short_uuid = panel_user.short_uuid
|
||||
|
||||
# Update crypto link
|
||||
if panel_user.happ_crypto_link and sub.subscription_crypto_link != panel_user.happ_crypto_link:
|
||||
changes['subscription_crypto_link'] = {'old': sub.subscription_crypto_link, 'new': '***'}
|
||||
sub.subscription_crypto_link = panel_user.happ_crypto_link
|
||||
|
||||
# Update traffic usage if requested
|
||||
if request.update_traffic and sync_sub:
|
||||
panel_traffic_used = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes else 0
|
||||
@@ -3037,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,
|
||||
@@ -3118,8 +3316,9 @@ async def sync_user_to_panel(
|
||||
|
||||
try:
|
||||
from app.config import settings
|
||||
from app.external.remnawave_api import TrafficLimitStrategy, UserStatus as PanelUserStatus
|
||||
from app.external.remnawave_api import UserStatus as PanelUserStatus
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import get_traffic_reset_strategy
|
||||
from app.utils.subscription_utils import resolve_hwid_device_limit_for_payload
|
||||
|
||||
service = RemnaWaveService()
|
||||
@@ -3218,7 +3417,7 @@ async def sync_user_to_panel(
|
||||
|
||||
if request.update_traffic_limit:
|
||||
update_kwargs['traffic_limit_bytes'] = traffic_limit_bytes
|
||||
update_kwargs['traffic_limit_strategy'] = TrafficLimitStrategy.MONTH
|
||||
update_kwargs['traffic_limit_strategy'] = get_traffic_reset_strategy(sub.tariff)
|
||||
changes['traffic_limit_gb'] = sub.traffic_limit_gb
|
||||
|
||||
if request.update_squads and sub.connected_squads:
|
||||
@@ -3239,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:
|
||||
@@ -3252,7 +3454,7 @@ async def sync_user_to_panel(
|
||||
'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)),
|
||||
'status': panel_status,
|
||||
'traffic_limit_bytes': traffic_limit_bytes,
|
||||
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
|
||||
'traffic_limit_strategy': get_traffic_reset_strategy(sub.tariff),
|
||||
'telegram_id': user.telegram_id,
|
||||
'email': user.email,
|
||||
'description': description,
|
||||
|
||||
@@ -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)
|
||||
+134
-74
@@ -144,96 +144,140 @@ async def _store_refresh_token(
|
||||
refresh_token: str,
|
||||
device_info: str | None = None,
|
||||
) -> None:
|
||||
"""Store refresh token hash in database."""
|
||||
"""Store refresh token hash in database using upsert to avoid duplicate key errors."""
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
||||
expires_at = get_refresh_token_expires_at()
|
||||
|
||||
token_record = CabinetRefreshToken(
|
||||
stmt = pg_insert(CabinetRefreshToken).values(
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
device_info=device_info,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(token_record)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
logger.debug('Refresh token already exists (duplicate)', user_id=user_id)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=['token_hash'],
|
||||
set_={
|
||||
'expires_at': expires_at,
|
||||
'device_info': device_info,
|
||||
'revoked_at': None,
|
||||
},
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.commit()
|
||||
|
||||
|
||||
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(
|
||||
@@ -578,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)
|
||||
|
||||
@@ -685,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)
|
||||
|
||||
@@ -809,10 +856,9 @@ async def auth_telegram_oidc(
|
||||
# Update user info from OIDC claims
|
||||
if username and username != user.username:
|
||||
user.username = username
|
||||
if first_name and first_name != user.first_name:
|
||||
user.first_name = first_name
|
||||
if last_name is not None and last_name != user.last_name:
|
||||
user.last_name = last_name
|
||||
# NOTE: не обновляем first_name/last_name из OIDC
|
||||
# Telegram OIDC возвращает только поле name как полное имя без разделения на first/last
|
||||
# Имя правильно заполняется через middleware при обычном использовании бота
|
||||
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
@@ -832,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)
|
||||
|
||||
@@ -1052,6 +1099,10 @@ async def register_email_standalone(
|
||||
referred_by_id=referrer.id if referrer else None,
|
||||
)
|
||||
|
||||
# Сохранить campaign_slug для обработки при верификации email
|
||||
if request.campaign_slug:
|
||||
user.pending_campaign_slug = request.campaign_slug
|
||||
|
||||
# Для тестового email или отключённой верификации - автоматически верифицировать
|
||||
if is_test_email or not settings.is_cabinet_email_verification_enabled():
|
||||
user.email_verified = True
|
||||
@@ -1063,6 +1114,11 @@ async def register_email_standalone(
|
||||
await _sync_subscription_from_panel_by_email(db, user)
|
||||
except Exception:
|
||||
logger.warning('Failed to sync panel subscription after auto-verify', user_id=user.id, exc_info=True)
|
||||
# Process campaign bonus immediately for auto-verified users
|
||||
if request.campaign_slug:
|
||||
await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
user.pending_campaign_slug = None
|
||||
await db.commit()
|
||||
else:
|
||||
# Сгенерировать токен верификации
|
||||
verification_token = generate_verification_token()
|
||||
@@ -1173,8 +1229,12 @@ async def verify_email(
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
# Process campaign bonus (prefer request param, fallback to saved slug from registration)
|
||||
effective_campaign_slug = request.campaign_slug or user.pending_campaign_slug
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, effective_campaign_slug)
|
||||
if user.pending_campaign_slug:
|
||||
user.pending_campaign_slug = None
|
||||
await db.commit()
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
|
||||
@@ -794,6 +794,219 @@ async def create_topup(
|
||||
detail='Failed to create SeverPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'paypear':
|
||||
if not settings.is_paypear_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='PayPear payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_paypear_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create PayPear payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'rollypay':
|
||||
if not settings.is_rollypay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='RollyPay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_rollypay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create RollyPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'overpay':
|
||||
if not settings.is_overpay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Overpay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_overpay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Overpay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'aurapay':
|
||||
if not settings.is_aurapay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='AuraPay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_aurapay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create AuraPay payment',
|
||||
)
|
||||
|
||||
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(
|
||||
@@ -945,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 '❓', 'Неизвестно'
|
||||
|
||||
|
||||
@@ -1086,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,
|
||||
)
|
||||
@@ -1111,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)
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.config import settings
|
||||
from app.database.crud.system_setting import get_setting_value
|
||||
from app.database.models import SystemSetting, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -291,12 +291,24 @@ class GiftEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class OfflineConvGoal(BaseModel):
|
||||
"""Yandex Metrika offline conversion goal descriptor."""
|
||||
|
||||
name: str
|
||||
event_id: str
|
||||
dedup: str
|
||||
|
||||
|
||||
class AnalyticsCountersResponse(BaseModel):
|
||||
"""Analytics counter settings."""
|
||||
|
||||
yandex_metrika_id: str = ''
|
||||
google_ads_id: str = ''
|
||||
google_ads_label: str = ''
|
||||
offline_conv_enabled: bool = False
|
||||
offline_conv_counter_id: str = ''
|
||||
offline_conv_measurement_secret_masked: str = ''
|
||||
offline_conv_goals: list[OfflineConvGoal] = []
|
||||
|
||||
|
||||
class AnalyticsCountersUpdate(BaseModel):
|
||||
@@ -924,10 +936,27 @@ async def get_analytics_counters(
|
||||
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
|
||||
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
|
||||
|
||||
# Yandex Metrika offline conversions snapshot from Settings
|
||||
oc_enabled = bool(getattr(settings, 'YANDEX_OFFLINE_CONV_ENABLED', False))
|
||||
oc_counter = str(getattr(settings, 'YANDEX_OFFLINE_CONV_COUNTER_ID', '') or '')
|
||||
oc_secret = str(getattr(settings, 'YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET', '') or '')
|
||||
oc_secret_masked = ('*' * 8 + oc_secret[-4:]) if len(oc_secret) > 4 else ('***' if oc_secret else '')
|
||||
oc_goals: list[OfflineConvGoal] = []
|
||||
if oc_enabled:
|
||||
oc_goals = [
|
||||
OfflineConvGoal(name='Registration', event_id='registration', dedup='user_id'),
|
||||
OfflineConvGoal(name='Trial', event_id='trial-add', dedup='user_id'),
|
||||
OfflineConvGoal(name='Purchase', event_id='purchase', dedup='order_id'),
|
||||
]
|
||||
|
||||
return AnalyticsCountersResponse(
|
||||
yandex_metrika_id=yandex_id,
|
||||
google_ads_id=google_id,
|
||||
google_ads_label=google_label,
|
||||
offline_conv_enabled=oc_enabled,
|
||||
offline_conv_counter_id=oc_counter,
|
||||
offline_conv_measurement_secret_masked=oc_secret_masked,
|
||||
offline_conv_goals=oc_goals,
|
||||
)
|
||||
|
||||
|
||||
@@ -966,13 +995,56 @@ async def update_analytics_counters(
|
||||
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
|
||||
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
|
||||
|
||||
oc_enabled = bool(getattr(settings, 'YANDEX_OFFLINE_CONV_ENABLED', False))
|
||||
oc_counter = str(getattr(settings, 'YANDEX_OFFLINE_CONV_COUNTER_ID', '') or '')
|
||||
oc_secret = str(getattr(settings, 'YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET', '') or '')
|
||||
oc_secret_masked = ('*' * 8 + oc_secret[-4:]) if len(oc_secret) > 4 else ('***' if oc_secret else '')
|
||||
oc_goals: list[OfflineConvGoal] = []
|
||||
if oc_enabled:
|
||||
oc_goals = [
|
||||
OfflineConvGoal(name='Registration', event_id='registration', dedup='user_id'),
|
||||
OfflineConvGoal(name='Trial', event_id='trial-add', dedup='user_id'),
|
||||
OfflineConvGoal(name='Purchase', event_id='purchase', dedup='order_id'),
|
||||
]
|
||||
|
||||
return AnalyticsCountersResponse(
|
||||
yandex_metrika_id=yandex_id,
|
||||
google_ads_id=google_id,
|
||||
google_ads_label=google_label,
|
||||
offline_conv_enabled=oc_enabled,
|
||||
offline_conv_counter_id=oc_counter,
|
||||
offline_conv_measurement_secret_masked=oc_secret_masked,
|
||||
offline_conv_goals=oc_goals,
|
||||
)
|
||||
|
||||
|
||||
# ============ Yandex CID Sync ============
|
||||
|
||||
|
||||
class YandexCidRequest(BaseModel):
|
||||
cid: str = Field(max_length=128, pattern=r'^[A-Za-z0-9._:-]{4,128}$')
|
||||
|
||||
|
||||
@router.post('/analytics/yandex-cid', status_code=204)
|
||||
async def store_yandex_cid(
|
||||
body: YandexCidRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Store Yandex Metrika ClientID for the authenticated cabinet user."""
|
||||
try:
|
||||
from app.services import yandex_offline_conv_service as yandex_conv
|
||||
|
||||
await yandex_conv.store_cid(db, user.id, body.cid, source='cabinet')
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to store yandex_cid', user_id=user.id, exc=str(exc))
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ============ Lite Mode Routes ============
|
||||
|
||||
|
||||
|
||||
@@ -425,8 +425,8 @@ async def create_gift_purchase(
|
||||
warning=recipient_warning,
|
||||
)
|
||||
|
||||
# Balance mode
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
# Balance mode (skip for 100% discount)
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Insufficient balance',
|
||||
@@ -517,6 +517,36 @@ async def create_gift_purchase(
|
||||
description=tx_description,
|
||||
)
|
||||
|
||||
# Tasks: триггерим прогресс по подаркам
|
||||
try:
|
||||
from app.database.models import TaskType as _TaskType
|
||||
from app.services.tasks_service import record_event as _record_event
|
||||
|
||||
await _record_event(
|
||||
db,
|
||||
user_id=user.id,
|
||||
event_type=_TaskType.GIFT_PURCHASED,
|
||||
payload={'purchase_id': purchase.id},
|
||||
)
|
||||
await _record_event(
|
||||
db,
|
||||
user_id=user.id,
|
||||
event_type=_TaskType.GIFTS_COUNT,
|
||||
payload={'purchase_id': purchase.id},
|
||||
)
|
||||
# record_event делает только flush(); коммитим явно. Для has_recipient=True далее
|
||||
# fulfill_purchase сделает свой commit, для has_recipient=False — это единственный
|
||||
# шанс закоммитить task-прогресс перед return.
|
||||
await db.commit()
|
||||
except Exception as task_err:
|
||||
# Сессия может быть в poisoned state — откатываем, чтобы fulfill_purchase ниже
|
||||
# мог продолжить работу с сессией.
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning('Tasks: ошибка GIFT триггеров', user_id=user.id, error=task_err)
|
||||
|
||||
# Capture token before fulfill_purchase — session state may change after rollback inside fulfill
|
||||
purchase_token = purchase.token
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Public info page routes for cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.info_pages import get_all_info_pages, get_info_page_by_slug, get_tab_replacements
|
||||
|
||||
from ..dependencies import get_cabinet_db
|
||||
from ..schemas.info_pages import InfoPageListItem, InfoPageResponse
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/info-pages', tags=['Cabinet Info Pages'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[InfoPageListItem])
|
||||
async def list_active_info_pages(
|
||||
page_type: str | None = Query(None, pattern=r'^(page|faq)$'),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[InfoPageListItem]:
|
||||
"""Get all active info pages (public, no auth required)."""
|
||||
try:
|
||||
pages = await get_all_info_pages(db, include_inactive=False, page_type=page_type)
|
||||
return [InfoPageListItem.model_validate(p) for p in pages]
|
||||
except Exception:
|
||||
logger.exception('Failed to list active info pages')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load info pages',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/tab-replacements')
|
||||
async def get_info_page_tab_replacements(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, str | None]:
|
||||
"""Get tab replacement mapping (public, no auth required).
|
||||
|
||||
Returns a dict mapping each replaceable tab to the info page slug that replaces it,
|
||||
or null if no replacement is set: ``{faq: slug_or_null, ...}``.
|
||||
"""
|
||||
try:
|
||||
return await get_tab_replacements(db)
|
||||
except Exception:
|
||||
logger.exception('Failed to get tab replacements')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load tab replacements',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{slug}', response_model=InfoPageResponse)
|
||||
async def get_info_page_by_slug_public(
|
||||
slug: str = Path(..., max_length=200, pattern=r'^[a-z0-9\-]+$'),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Get a single info page by slug (public, no auth required)."""
|
||||
page = await get_info_page_by_slug(db, slug)
|
||||
|
||||
if not page or not page.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
return InfoPageResponse.model_validate(page)
|
||||
@@ -23,7 +23,7 @@ from app.services.guest_purchase_service import (
|
||||
)
|
||||
from app.services.payment_method_config_service import _get_method_defaults
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.cache import RateLimitCache
|
||||
from app.utils.cache import RateLimitCache, cache
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -99,6 +99,11 @@ class LandingConfigResponse(BaseModel):
|
||||
meta_description: str | None = None
|
||||
discount: LandingDiscountInfo | None = None # null if no active discount
|
||||
background_config: dict | None = None
|
||||
sticky_pay_button: bool = False
|
||||
analytics_view_enabled: bool = False
|
||||
analytics_view_goal: str | None = None
|
||||
analytics_click_enabled: bool = False
|
||||
analytics_click_goal: str | None = None
|
||||
|
||||
|
||||
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
|
||||
@@ -123,6 +128,9 @@ class PurchaseRequest(BaseModel):
|
||||
gift_recipient_type: str | None = Field(default=None, pattern=r'^(email|telegram)$')
|
||||
gift_recipient_value: str | None = Field(default=None, max_length=255)
|
||||
gift_message: str | None = Field(default=None, max_length=1000)
|
||||
yandex_cid: str | None = Field(default=None, max_length=128, pattern=r'^[A-Za-z0-9._:-]{4,128}$')
|
||||
referrer: str | None = Field(default=None, max_length=500)
|
||||
subid: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_contacts(self) -> 'PurchaseRequest':
|
||||
@@ -535,6 +543,11 @@ async def get_landing_config(
|
||||
meta_description=resolve_locale_text(landing.meta_description, lang) or None,
|
||||
discount=discount,
|
||||
background_config=landing.background_config,
|
||||
sticky_pay_button=landing.sticky_pay_button,
|
||||
analytics_view_enabled=landing.analytics_view_enabled,
|
||||
analytics_view_goal=landing.analytics_view_goal,
|
||||
analytics_click_enabled=landing.analytics_click_enabled,
|
||||
analytics_click_goal=landing.analytics_click_goal,
|
||||
)
|
||||
|
||||
|
||||
@@ -644,9 +657,17 @@ async def create_landing_purchase(
|
||||
gift_recipient_type=body.gift_recipient_type,
|
||||
gift_recipient_value=body.gift_recipient_value,
|
||||
gift_message=body.gift_message,
|
||||
subid=body.subid,
|
||||
referrer=body.referrer,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Fallback to HTTP Referer header if body did not supply one
|
||||
if not purchase.referrer:
|
||||
http_referrer = raw_request.headers.get('referer') or raw_request.headers.get('referrer')
|
||||
if http_referrer and len(http_referrer) <= 500:
|
||||
purchase.referrer = http_referrer
|
||||
|
||||
# Determine return URL: per-method override → default cabinet URL
|
||||
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
|
||||
default_return_url = f'{cabinet_base}/buy/success/{purchase.token}'
|
||||
@@ -690,6 +711,20 @@ async def create_landing_purchase(
|
||||
await db.commit()
|
||||
await db.refresh(purchase)
|
||||
|
||||
# Persist Yandex CID in cache so fulfill_purchase can link it to the user later
|
||||
if body.yandex_cid and settings.YANDEX_OFFLINE_CONV_ENABLED:
|
||||
try:
|
||||
await cache.set(f'yacid:purchase:{purchase.token}', body.yandex_cid, expire=86400)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Persist subid in cache for S2S postback
|
||||
if body.subid:
|
||||
try:
|
||||
await cache.set(f'subid:purchase:{purchase.token}', body.subid, expire=86400)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return PurchaseResponse(
|
||||
purchase_token=purchase.token,
|
||||
payment_url=payment_url,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -28,7 +28,7 @@ class NotificationSettingsResponse(BaseModel):
|
||||
subscription_expiry_days: int = 3
|
||||
traffic_warning_enabled: bool = True
|
||||
traffic_warning_percent: int = 80
|
||||
balance_low_enabled: bool = True
|
||||
balance_low_enabled: bool = False
|
||||
balance_low_threshold: int = 100 # kopeks
|
||||
news_enabled: bool = True
|
||||
promo_offers_enabled: bool = True
|
||||
@@ -60,7 +60,7 @@ def _get_notification_settings(user: User) -> dict[str, Any]:
|
||||
'subscription_expiry_days': settings_data.get('subscription_expiry_days', 3),
|
||||
'traffic_warning_enabled': settings_data.get('traffic_warning_enabled', True),
|
||||
'traffic_warning_percent': settings_data.get('traffic_warning_percent', 80),
|
||||
'balance_low_enabled': settings_data.get('balance_low_enabled', True),
|
||||
'balance_low_enabled': settings_data.get('balance_low_enabled', False),
|
||||
'balance_low_threshold': settings_data.get('balance_low_threshold', 100),
|
||||
'news_enabled': settings_data.get('news_enabled', True),
|
||||
'promo_offers_enabled': settings_data.get('promo_offers_enabled', True),
|
||||
|
||||
@@ -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',
|
||||
@@ -79,6 +103,8 @@ async def activate_promocode(
|
||||
error_messages = {
|
||||
'not_found': 'Promo code not found',
|
||||
'expired': 'Promo code has expired',
|
||||
'inactive': 'Promo code is deactivated',
|
||||
'not_yet_valid': 'Promo code is not yet active',
|
||||
'used': 'Promo code has been fully used',
|
||||
'already_used_by_user': 'You have already used this promo code',
|
||||
'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -168,6 +168,13 @@ async def toggle_subscription_pause(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Error syncing RemnaWave user on resume', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='create',
|
||||
)
|
||||
|
||||
if new_paused_state:
|
||||
message = 'Daily subscription paused'
|
||||
|
||||
@@ -13,6 +13,7 @@ POST /subscription/devices/save-cart
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -108,7 +109,24 @@ async def purchase_devices_legacy(
|
||||
detail='Докупка устройств недоступна',
|
||||
)
|
||||
|
||||
base_total_price = device_price * request.devices
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
current_devices = subscription.device_limit or 1
|
||||
if tariff:
|
||||
tariff_included = tariff.device_limit or 0
|
||||
if current_devices < tariff_included:
|
||||
free_devices = tariff_included - current_devices
|
||||
chargeable_devices = max(0, request.devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = request.devices
|
||||
else:
|
||||
free_baseline = settings.DEFAULT_DEVICE_LIMIT
|
||||
if current_devices < free_baseline:
|
||||
free_devices = free_baseline - current_devices
|
||||
chargeable_devices = max(0, request.devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = request.devices
|
||||
|
||||
base_total_price = device_price * chargeable_devices
|
||||
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
@@ -134,8 +152,8 @@ async def purchase_devices_legacy(
|
||||
detail=f'Максимальное количество устройств: {max_device_limit}',
|
||||
)
|
||||
|
||||
# Check balance
|
||||
if user.balance_kopeks < total_price:
|
||||
# Check balance (skip for 100% discount)
|
||||
if total_price > 0 and user.balance_kopeks < total_price:
|
||||
missing = total_price - user.balance_kopeks
|
||||
|
||||
# Сохраняем корзину для автопокупки после пополнения
|
||||
@@ -228,12 +246,24 @@ async def purchase_devices_legacy(
|
||||
# Sync with RemnaWave
|
||||
try:
|
||||
service = SubscriptionService()
|
||||
if _resolve_panel_uuid(subscription, user):
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
await service.create_remnawave_user(db, subscription)
|
||||
else:
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync devices with RemnaWave (legacy endpoint)', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='create' if _should_create else 'update',
|
||||
)
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
@@ -351,13 +381,30 @@ 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
|
||||
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
if tariff:
|
||||
tariff_included = tariff.device_limit or 0
|
||||
if current_devices < tariff_included:
|
||||
free_devices = tariff_included - current_devices
|
||||
chargeable_devices = max(0, request.devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = request.devices
|
||||
else:
|
||||
free_baseline = settings.DEFAULT_DEVICE_LIMIT
|
||||
if current_devices < free_baseline:
|
||||
free_devices = free_baseline - current_devices
|
||||
chargeable_devices = max(0, request.devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = request.devices
|
||||
|
||||
# Calculate base price before discount
|
||||
base_price_per_month = device_price * request.devices
|
||||
base_price_per_month = device_price * chargeable_devices
|
||||
base_price_prorated = int(base_price_per_month * days_left / total_days)
|
||||
base_price_prorated = max(100, base_price_prorated) # Minimum 1 ruble
|
||||
if chargeable_devices > 0:
|
||||
base_price_prorated = max(100, base_price_prorated) # Minimum 1 ruble
|
||||
|
||||
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
@@ -375,8 +422,8 @@ async def purchase_devices(
|
||||
if devices_discount_percent < 100:
|
||||
price_kopeks = max(100, price_kopeks)
|
||||
|
||||
# Check balance
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
# Check balance (skip for 100% discount)
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
missing = price_kopeks - user.balance_kopeks
|
||||
|
||||
# Сохраняем корзину для автопокупки после пополнения
|
||||
@@ -469,12 +516,24 @@ async def purchase_devices(
|
||||
# Sync with RemnaWave
|
||||
service = SubscriptionService()
|
||||
try:
|
||||
if _resolve_panel_uuid(subscription, user):
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
await service.create_remnawave_user(db, subscription)
|
||||
else:
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync devices with RemnaWave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='create' if _should_create else 'update',
|
||||
)
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -600,11 +659,28 @@ 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
|
||||
|
||||
base_total_price = int(device_price * request.devices * days_left / total_days)
|
||||
base_total_price = max(100, base_total_price) # Minimum 1 ruble
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
if tariff:
|
||||
tariff_included = tariff.device_limit or 0
|
||||
if current_devices < tariff_included:
|
||||
free_devices = tariff_included - current_devices
|
||||
chargeable_devices = max(0, request.devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = request.devices
|
||||
else:
|
||||
free_baseline = settings.DEFAULT_DEVICE_LIMIT
|
||||
if current_devices < free_baseline:
|
||||
free_devices = free_baseline - current_devices
|
||||
chargeable_devices = max(0, request.devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = request.devices
|
||||
|
||||
base_total_price = int(device_price * chargeable_devices * days_left / total_days)
|
||||
if chargeable_devices > 0:
|
||||
base_total_price = max(100, base_total_price) # Minimum 1 ruble
|
||||
|
||||
# Apply discount from promo group
|
||||
period_hint_days = days_left
|
||||
@@ -697,12 +773,29 @@ 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
|
||||
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
if tariff:
|
||||
tariff_included = tariff.device_limit or 0
|
||||
if current_devices < tariff_included:
|
||||
free_devices = tariff_included - current_devices
|
||||
chargeable_devices = max(0, devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = devices
|
||||
else:
|
||||
free_baseline = settings.DEFAULT_DEVICE_LIMIT
|
||||
if current_devices < free_baseline:
|
||||
free_devices = free_baseline - current_devices
|
||||
chargeable_devices = max(0, devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = devices
|
||||
|
||||
# Calculate base price before discount (total first, then floor)
|
||||
base_total_price = int(device_price * devices * days_left / total_days)
|
||||
base_total_price = max(100, base_total_price)
|
||||
base_total_price = int(device_price * chargeable_devices * days_left / total_days)
|
||||
if chargeable_devices > 0:
|
||||
base_total_price = max(100, base_total_price)
|
||||
|
||||
# Apply discount from promo group
|
||||
period_hint_days = days_left
|
||||
|
||||
@@ -304,9 +304,7 @@ async def get_purchase_options(
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
||||
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
||||
purchased_tariff_ids = {
|
||||
s.tariff_id for s in active_subs if s.tariff_id and s.status in ('active', 'trial')
|
||||
}
|
||||
purchased_tariff_ids = {s.tariff_id for s in active_subs if s.tariff_id and not s.is_trial}
|
||||
|
||||
if subscription_id:
|
||||
from app.database.crud.subscription import get_subscription_by_id_for_user
|
||||
@@ -355,6 +353,9 @@ async def get_purchase_options(
|
||||
'all_tariffs_purchased': len(purchased_tariff_ids) >= len(tariffs)
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else False,
|
||||
# Направления смены тарифа
|
||||
'tariff_switch_upgrade_enabled': settings.TARIFF_SWITCH_UPGRADE_ENABLED,
|
||||
'tariff_switch_downgrade_enabled': settings.TARIFF_SWITCH_DOWNGRADE_ENABLED,
|
||||
}
|
||||
|
||||
# Classic mode - return periods
|
||||
@@ -678,15 +679,17 @@ async def purchase_tariff(
|
||||
promo_offer_discount_value = result.promo_offer_discount
|
||||
price_before_promo_offer = price_kopeks + promo_offer_discount_value
|
||||
|
||||
# Safety guard: reject zero-price purchases for non-daily tariffs (defense in depth)
|
||||
if price_kopeks <= 0 and result.base_price <= 0 and not is_daily_tariff:
|
||||
# Safety guard: reject zero-price purchases for non-daily tariffs (defense in depth).
|
||||
# Use original_total (pre-discount price) — base_price is already discounted,
|
||||
# so a 100% group discount legitimately makes it 0.
|
||||
if price_kopeks <= 0 and result.original_total <= 0 and not is_daily_tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid tariff period or pricing configuration',
|
||||
)
|
||||
|
||||
# Check balance
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
missing = price_kopeks - user.balance_kopeks
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
@@ -892,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,
|
||||
@@ -902,7 +911,6 @@ async def purchase_tariff(
|
||||
sync_squads=True,
|
||||
)
|
||||
else:
|
||||
# New subscription — create new Remnawave user
|
||||
await service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
@@ -911,6 +919,13 @@ async def purchase_tariff(
|
||||
)
|
||||
except Exception as remnawave_error:
|
||||
logger.error('Failed to sync subscription with RemnaWave', remnawave_error=remnawave_error)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='create' if _should_create else 'update',
|
||||
)
|
||||
|
||||
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
|
||||
if not is_daily_tariff:
|
||||
@@ -1158,7 +1173,7 @@ async def activate_trial(
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
price_kopeks = settings.TRIAL_ACTIVATION_PRICE
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Insufficient balance. Need {price_kopeks / 100:.2f} RUB',
|
||||
@@ -1212,9 +1227,11 @@ async def activate_trial(
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
trial_device_limit = trial_tariff.device_limit
|
||||
trial_squads = trial_tariff.allowed_squads or []
|
||||
trial_squads = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
|
||||
tariff_id_for_trial = trial_tariff.id
|
||||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
@@ -1228,6 +1245,13 @@ async def activate_trial(
|
||||
except Exception as e:
|
||||
logger.error('Error getting trial tariff', error=e)
|
||||
|
||||
# No trial tariff configured, use the legacy random trial squad fallback.
|
||||
if not trial_squads:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
trial_squads = [trial_squad_uuid] if trial_squad_uuid else []
|
||||
|
||||
# Create trial subscription
|
||||
subscription = await create_trial_subscription(
|
||||
db=db,
|
||||
@@ -1249,6 +1273,13 @@ async def activate_trial(
|
||||
await db.refresh(subscription)
|
||||
except Exception as e:
|
||||
logger.error('Failed to create RemnaWave user for trial', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='create',
|
||||
)
|
||||
|
||||
# Send admin notification about trial activation
|
||||
try:
|
||||
|
||||
@@ -57,7 +57,14 @@ async def get_renewal_options(
|
||||
return []
|
||||
|
||||
# Determine available periods
|
||||
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
|
||||
# Скрытый/неактивный тариф (например, триальный после промокода) —
|
||||
# не показываем его периоды, используем стандартные
|
||||
if (
|
||||
subscription.tariff_id
|
||||
and subscription.tariff
|
||||
and subscription.tariff.is_active
|
||||
and subscription.tariff.period_prices
|
||||
):
|
||||
periods = sorted(int(k) for k in subscription.tariff.period_prices.keys())
|
||||
else:
|
||||
periods = settings.get_available_renewal_periods()
|
||||
@@ -67,7 +74,7 @@ async def get_renewal_options(
|
||||
for period in periods:
|
||||
pricing = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
|
||||
|
||||
if pricing.final_total <= 0 and pricing.base_price <= 0:
|
||||
if pricing.final_total <= 0 and pricing.original_total <= 0:
|
||||
continue
|
||||
|
||||
original_price = pricing.original_total
|
||||
@@ -128,7 +135,12 @@ async def renew_subscription(
|
||||
detail=f'Cannot renew subscription with status: {_actual_status}',
|
||||
)
|
||||
|
||||
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
|
||||
if (
|
||||
subscription.tariff_id
|
||||
and subscription.tariff
|
||||
and subscription.tariff.is_active
|
||||
and subscription.tariff.period_prices
|
||||
):
|
||||
available_periods = [int(p) for p in subscription.tariff.period_prices.keys()]
|
||||
else:
|
||||
available_periods = settings.get_available_renewal_periods()
|
||||
@@ -155,7 +167,7 @@ async def renew_subscription(
|
||||
promo_offer_discount_value = pricing.promo_offer_discount
|
||||
promo_offer_discount_percent = pricing.breakdown.get('offer_discount_pct', 0)
|
||||
|
||||
if price_kopeks <= 0 and pricing.base_price <= 0:
|
||||
if price_kopeks <= 0 and pricing.original_total <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid renewal period',
|
||||
@@ -168,8 +180,8 @@ async def renew_subscription(
|
||||
|
||||
tariff = subscription.tariff if subscription.tariff_id else None
|
||||
|
||||
# Check balance
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
# Check balance (skip for 100% discount)
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
missing = price_kopeks - user.balance_kopeks
|
||||
|
||||
# Get tariff info for cart
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -249,6 +249,13 @@ async def update_countries(
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync countries with RemnaWave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='update' if _has_panel else 'create',
|
||||
)
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
|
||||
@@ -119,6 +119,18 @@ async def preview_tariff_switch(
|
||||
)
|
||||
upgrade_cost = switch_result.upgrade_cost
|
||||
is_upgrade = switch_result.is_upgrade
|
||||
|
||||
# Проверяем разрешение на смену в данном направлении
|
||||
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Повышение тарифа недоступно',
|
||||
)
|
||||
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Понижение тарифа недоступно',
|
||||
)
|
||||
base_upgrade_cost = switch_result.raw_cost
|
||||
discount_value = switch_result.discount_value
|
||||
period_discount_percent = switch_result.effective_discount_pct
|
||||
@@ -263,11 +275,24 @@ async def switch_tariff(
|
||||
user=user,
|
||||
)
|
||||
upgrade_cost = switch_result.upgrade_cost
|
||||
is_upgrade = switch_result.is_upgrade
|
||||
base_upgrade_cost = switch_result.raw_cost
|
||||
discount_value = switch_result.discount_value
|
||||
period_discount_percent = switch_result.effective_discount_pct
|
||||
new_period_days = switch_result.new_period_days
|
||||
|
||||
# Проверяем разрешение на смену в данном направлении
|
||||
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Повышение тарифа недоступно',
|
||||
)
|
||||
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Понижение тарифа недоступно',
|
||||
)
|
||||
|
||||
# Validate daily price for switching TO daily
|
||||
new_is_daily = getattr(new_tariff, 'is_daily', False)
|
||||
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
|
||||
@@ -428,6 +453,13 @@ async def switch_tariff(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync tariff switch with RemnaWave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='update' if _has_panel else 'create',
|
||||
)
|
||||
|
||||
# Reset all devices on tariff switch
|
||||
devices_reset = False
|
||||
|
||||
@@ -9,6 +9,7 @@ POST /subscription/traffic/save-cart
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -254,7 +255,7 @@ async def purchase_traffic(
|
||||
final_price = max(100, final_price)
|
||||
|
||||
# Проверяем баланс
|
||||
if user.balance_kopeks < final_price:
|
||||
if final_price > 0 and user.balance_kopeks < final_price:
|
||||
missing = final_price - user.balance_kopeks
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
@@ -316,19 +317,32 @@ async def purchase_traffic(
|
||||
# Синхронизируем с RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
_panel_uuid = (
|
||||
subscription.remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
|
||||
else getattr(user, 'remnawave_uuid', None)
|
||||
)
|
||||
if _panel_uuid:
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
else:
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
if subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(_panel_uuid)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
_enable_uuid = (
|
||||
subscription.remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else getattr(user, 'remnawave_uuid', None)
|
||||
)
|
||||
if _enable_uuid:
|
||||
await subscription_service.enable_remnawave_user(_enable_uuid)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync traffic with RemnaWave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='create' if _should_create else 'update',
|
||||
)
|
||||
|
||||
# Создаём транзакцию
|
||||
await create_transaction(
|
||||
@@ -465,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,
|
||||
@@ -560,7 +574,7 @@ async def switch_traffic_package(
|
||||
# Prorated calculation
|
||||
final_price, days_charged = calculate_prorated_price(price_diff, subscription.end_date)
|
||||
|
||||
if user.balance_kopeks < final_price:
|
||||
if final_price > 0 and user.balance_kopeks < final_price:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail=f'Insufficient balance. Need {final_price / 100:.2f} RUB',
|
||||
@@ -604,17 +618,25 @@ async def switch_traffic_package(
|
||||
# Sync with RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
_panel_uuid2 = (
|
||||
subscription.remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
|
||||
else getattr(user, 'remnawave_uuid', None)
|
||||
)
|
||||
if _panel_uuid2:
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
else:
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync traffic switch with RemnaWave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=subscription.user_id,
|
||||
action='create' if _should_create else 'update',
|
||||
)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
@@ -20,6 +20,7 @@ from ..schemas.tickets import (
|
||||
TicketCreateRequest,
|
||||
TicketDetailResponse,
|
||||
TicketListResponse,
|
||||
TicketMediaItem,
|
||||
TicketMessageCreateRequest,
|
||||
TicketMessageResponse,
|
||||
TicketResponse,
|
||||
@@ -33,14 +34,23 @@ router = APIRouter(prefix='/tickets', tags=['Cabinet Tickets'])
|
||||
|
||||
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
|
||||
"""Convert TicketMessage to response."""
|
||||
raw_items = getattr(message, 'media_items', None) or None
|
||||
items = None
|
||||
if raw_items:
|
||||
try:
|
||||
items = [TicketMediaItem(**it) for it in raw_items]
|
||||
except (TypeError, KeyError, ValueError) as exc:
|
||||
logger.warning('Failed to parse media_items', message_id=message.id, error=str(exc))
|
||||
items = None
|
||||
return TicketMessageResponse(
|
||||
id=message.id,
|
||||
message_text=message.message_text or '',
|
||||
is_from_admin=message.is_from_admin,
|
||||
has_media=bool(message.media_file_id),
|
||||
has_media=bool(message.media_file_id) or bool(items),
|
||||
media_type=message.media_type,
|
||||
media_file_id=message.media_file_id,
|
||||
media_caption=message.media_caption,
|
||||
media_items=items,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
@@ -143,15 +153,30 @@ async def create_ticket(
|
||||
db.add(ticket)
|
||||
await db.flush()
|
||||
|
||||
# Resolve media payload
|
||||
items_payload = None
|
||||
primary_type = request.media_type
|
||||
primary_file_id = request.media_file_id
|
||||
primary_caption = request.media_caption
|
||||
if getattr(request, 'media_items', None):
|
||||
items_payload = [it.model_dump() for it in request.media_items]
|
||||
first = request.media_items[0]
|
||||
primary_type = first.type
|
||||
primary_file_id = first.file_id
|
||||
primary_caption = primary_caption or first.caption
|
||||
|
||||
# Create initial message with optional media
|
||||
has_media = bool(primary_file_id)
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user.id,
|
||||
message_text=request.message,
|
||||
is_from_admin=False,
|
||||
media_type=request.media_type,
|
||||
media_file_id=request.media_file_id,
|
||||
media_caption=request.media_caption,
|
||||
has_media=has_media,
|
||||
media_type=primary_type if has_media else None,
|
||||
media_file_id=primary_file_id if has_media else None,
|
||||
media_caption=primary_caption if has_media else None,
|
||||
media_items=items_payload,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
@@ -259,15 +284,30 @@ async def add_ticket_message(
|
||||
detail='Replies to this ticket are blocked',
|
||||
)
|
||||
|
||||
# Resolve media payload
|
||||
items_payload = None
|
||||
primary_type = request.media_type
|
||||
primary_file_id = request.media_file_id
|
||||
primary_caption = request.media_caption
|
||||
if getattr(request, 'media_items', None):
|
||||
items_payload = [it.model_dump() for it in request.media_items]
|
||||
first = request.media_items[0]
|
||||
primary_type = first.type
|
||||
primary_file_id = first.file_id
|
||||
primary_caption = primary_caption or first.caption
|
||||
|
||||
# Create message with optional media
|
||||
has_media = bool(primary_file_id)
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user.id,
|
||||
message_text=request.message,
|
||||
is_from_admin=False,
|
||||
media_type=request.media_type,
|
||||
media_file_id=request.media_file_id,
|
||||
media_caption=request.media_caption,
|
||||
has_media=has_media,
|
||||
media_type=primary_type if has_media else None,
|
||||
media_file_id=primary_file_id if has_media else None,
|
||||
media_caption=primary_caption if has_media else None,
|
||||
media_items=items_payload,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
@@ -286,8 +326,8 @@ async def add_ticket_message(
|
||||
ticket,
|
||||
request.message,
|
||||
db,
|
||||
media_file_id=request.media_file_id,
|
||||
media_type=request.media_type,
|
||||
media_file_id=primary_file_id,
|
||||
media_type=primary_type,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Error notifying admins about ticket reply from cabinet', error=e)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""User-side endpoints для системы заданий с наградами."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.cabinet.dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from app.cabinet.schemas.tasks import (
|
||||
ClaimRewardRequest,
|
||||
ClaimRewardResponse,
|
||||
UserTaskProgressResponse,
|
||||
UserTasksAvailabilityResponse,
|
||||
UserTasksListResponse,
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.services import tasks_service
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/tasks', tags=['Cabinet Tasks'])
|
||||
|
||||
|
||||
@router.get('/availability', response_model=UserTasksAvailabilityResponse)
|
||||
async def get_tasks_availability(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Краткая информация для условного показа вкладки «Задания» в меню."""
|
||||
visible = await tasks_service.get_available_tasks_for_user(db, user)
|
||||
has_available = len(visible) > 0
|
||||
unclaimed = await tasks_service.count_completed_unclaimed(db, user_id=user.id)
|
||||
return UserTasksAvailabilityResponse(
|
||||
has_available_tasks=has_available,
|
||||
unclaimed_count=unclaimed,
|
||||
)
|
||||
|
||||
|
||||
@router.get('', response_model=UserTasksListResponse)
|
||||
async def list_my_tasks(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Список доступных заданий пользователя с их прогрессом."""
|
||||
visible = await tasks_service.get_available_tasks_for_user(db, user)
|
||||
|
||||
items: list[UserTaskProgressResponse] = []
|
||||
unclaimed_count = 0
|
||||
|
||||
for task, progress in visible:
|
||||
current_value = progress.current_value if progress else 0
|
||||
is_completed = progress.completed_at is not None if progress else False
|
||||
is_claimed = progress.claimed_at is not None if progress else False
|
||||
if is_completed and not is_claimed:
|
||||
unclaimed_count += 1
|
||||
percent = (
|
||||
int(min(current_value, task.target_value) / max(task.target_value, 1) * 100)
|
||||
if task.target_value
|
||||
else 0
|
||||
)
|
||||
items.append(
|
||||
UserTaskProgressResponse(
|
||||
task_id=task.id,
|
||||
title=task.title or {},
|
||||
description=task.description or {},
|
||||
icon=task.icon,
|
||||
task_type=task.task_type,
|
||||
target_value=task.target_value,
|
||||
target_meta=task.target_meta or {},
|
||||
reward_type=task.reward_type,
|
||||
reward_value=task.reward_value,
|
||||
reward_meta=task.reward_meta or {},
|
||||
allow_user_choice=task.allow_user_choice,
|
||||
level=task.level,
|
||||
parent_task_id=task.parent_task_id,
|
||||
current_value=current_value,
|
||||
percent=percent,
|
||||
is_completed=is_completed,
|
||||
is_claimed=is_claimed,
|
||||
completed_at=progress.completed_at if progress else None,
|
||||
claimed_at=progress.claimed_at if progress else None,
|
||||
reward_granted_meta=progress.reward_granted_meta if progress else None,
|
||||
)
|
||||
)
|
||||
|
||||
return UserTasksListResponse(
|
||||
items=items,
|
||||
has_unclaimed=unclaimed_count > 0,
|
||||
unclaimed_count=unclaimed_count,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{task_id}/claim', response_model=ClaimRewardResponse)
|
||||
async def claim_task_reward(
|
||||
task_id: int,
|
||||
request: ClaimRewardRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить награду за выполненное задание."""
|
||||
try:
|
||||
granted = await tasks_service.claim_reward(
|
||||
db,
|
||||
user_id=user.id,
|
||||
task_id=task_id,
|
||||
chosen_subscription_id=request.chosen_subscription_id,
|
||||
chosen_reward_type=request.chosen_reward_type,
|
||||
)
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
# Маппим внутренние коды на HTTP-статусы
|
||||
not_found = {'progress_not_found', 'task_not_found', 'user_not_found'}
|
||||
bad_request = {
|
||||
'not_completed',
|
||||
'already_claimed',
|
||||
'user_not_eligible',
|
||||
'user_choice_not_allowed',
|
||||
'no_paid_subscription',
|
||||
'no_subscription_with_target_tariff',
|
||||
'chosen_subscription_invalid',
|
||||
'need_choose_subscription',
|
||||
'invalid_reward_amount',
|
||||
'invalid_reward_days',
|
||||
}
|
||||
if msg in not_found:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=msg) from exc
|
||||
if msg in bad_request or msg.startswith('unknown_reward_type'):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=msg) from exc
|
||||
logger.exception('claim_reward unexpected error', error=msg)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='internal_error'
|
||||
) from exc
|
||||
|
||||
return ClaimRewardResponse(success=True, reward=granted)
|
||||
@@ -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
|
||||
@@ -138,6 +138,9 @@ class EmailRegisterStandaloneRequest(BaseModel):
|
||||
referral_code: str | None = Field(
|
||||
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
|
||||
)
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
|
||||
|
||||
class CampaignBonusInfo(BaseModel):
|
||||
|
||||
@@ -118,6 +118,7 @@ class BroadcastCreateRequest(BaseModel):
|
||||
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
|
||||
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
|
||||
media: BroadcastMediaRequest | None = None
|
||||
category: str = Field(default='system', pattern='^(system|news|promo)$')
|
||||
|
||||
|
||||
# ============ Response ============
|
||||
@@ -144,6 +145,9 @@ class BroadcastResponse(BaseModel):
|
||||
completed_at: datetime | None = None
|
||||
progress_percent: float = 0.0
|
||||
|
||||
# Category for user notification preference filtering
|
||||
category: str = 'system' # system|news|promo
|
||||
|
||||
# Email/channel fields
|
||||
channel: str = 'telegram' # telegram|email|both
|
||||
email_subject: str | None = None
|
||||
@@ -212,6 +216,9 @@ class CombinedBroadcastCreateRequest(BaseModel):
|
||||
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
|
||||
media: BroadcastMediaRequest | None = None
|
||||
|
||||
# Broadcast category for user notification preference filtering
|
||||
category: str = Field(default='system', pattern='^(system|news|promo)$')
|
||||
|
||||
# Email-specific fields
|
||||
email_subject: str | None = Field(default=None, max_length=255)
|
||||
email_html_content: str | None = Field(default=None, max_length=100000)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Schemas for admin bulk actions."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class BulkActionType(StrEnum):
|
||||
EXTEND_SUBSCRIPTION = 'extend_subscription'
|
||||
CANCEL_SUBSCRIPTION = 'cancel_subscription'
|
||||
ACTIVATE_SUBSCRIPTION = 'activate_subscription'
|
||||
CHANGE_TARIFF = 'change_tariff'
|
||||
ADD_DAYS = 'add_days'
|
||||
ADD_TRAFFIC = 'add_traffic'
|
||||
ADD_BALANCE = 'add_balance'
|
||||
ASSIGN_PROMO_GROUP = 'assign_promo_group'
|
||||
GRANT_SUBSCRIPTION = 'grant_subscription'
|
||||
SET_DEVICES = 'set_devices'
|
||||
DELETE_SUBSCRIPTION = 'delete_subscription'
|
||||
DELETE_USER = 'delete_user'
|
||||
|
||||
|
||||
class BulkActionParams(BaseModel):
|
||||
days: int | None = Field(None, ge=1, le=3650)
|
||||
tariff_id: int | None = Field(None, gt=0)
|
||||
traffic_gb: int | None = Field(None, ge=1, le=10000)
|
||||
amount_kopeks: int | None = Field(None, ge=1, le=2_000_000_000)
|
||||
balance_description: str = Field(default='Массовое начисление баланса', max_length=500)
|
||||
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):
|
||||
id: int
|
||||
tariff_id: int | None = None
|
||||
tariff_name: str | None = None
|
||||
status: str
|
||||
days_remaining: int
|
||||
traffic_used_gb: float = 0
|
||||
traffic_limit_gb: int = 0
|
||||
device_limit: int = 0
|
||||
|
||||
|
||||
class BulkExecuteRequest(BaseModel):
|
||||
action: BulkActionType
|
||||
user_ids: list[int] | None = Field(None, min_length=1, max_length=500)
|
||||
subscription_ids: list[int] | None = Field(None, min_length=1, max_length=2000)
|
||||
params: BulkActionParams = Field(default_factory=BulkActionParams)
|
||||
dry_run: bool = Field(default=False, description='Preview only, no mutations')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _exactly_one_target(self):
|
||||
has_users = self.user_ids is not None
|
||||
has_subs = self.subscription_ids is not None
|
||||
if has_users == has_subs:
|
||||
raise ValueError('Exactly one of user_ids or subscription_ids must be provided')
|
||||
return self
|
||||
|
||||
|
||||
class BulkUserResult(BaseModel):
|
||||
user_id: int
|
||||
subscription_id: int | None = None
|
||||
success: bool
|
||||
message: str
|
||||
username: str | None = None
|
||||
subscriptions: list[BulkSubscriptionInfo] | None = None
|
||||
|
||||
|
||||
class BulkExecuteResponse(BaseModel):
|
||||
action: str
|
||||
total: int
|
||||
success_count: int
|
||||
error_count: int
|
||||
skipped_count: int
|
||||
dry_run: bool
|
||||
results: list[BulkUserResult]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Schemas for info pages in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class InfoPageResponse(BaseModel):
|
||||
"""Full info page response."""
|
||||
|
||||
id: int
|
||||
slug: str
|
||||
title: dict[str, str]
|
||||
content: dict[str, str]
|
||||
page_type: str = 'page'
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
icon: str | None = None
|
||||
replaces_tab: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InfoPageListItem(BaseModel):
|
||||
"""Compact info page for list views."""
|
||||
|
||||
id: int
|
||||
slug: str
|
||||
title: dict[str, str]
|
||||
page_type: str = 'page'
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
icon: str | None = None
|
||||
replaces_tab: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InfoPageCreateRequest(BaseModel):
|
||||
"""Request to create an info page."""
|
||||
|
||||
slug: str = Field(min_length=1, max_length=200, pattern=r'^[a-z0-9\-]+$')
|
||||
title: dict[str, str] = Field(default_factory=dict)
|
||||
content: dict[str, str] = Field(default_factory=dict)
|
||||
page_type: str = Field(default='page', pattern=r'^(page|faq)$')
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
icon: str | None = Field(None, max_length=50)
|
||||
replaces_tab: str | None = Field(None, pattern=r'^(faq|rules|privacy|offer)$')
|
||||
|
||||
|
||||
class InfoPageUpdateRequest(BaseModel):
|
||||
"""Request to update an info page."""
|
||||
|
||||
slug: str | None = Field(None, min_length=1, max_length=200, pattern=r'^[a-z0-9\-]+$')
|
||||
title: dict[str, str] | None = None
|
||||
content: dict[str, str] | None = None
|
||||
page_type: str | None = Field(None, pattern=r'^(page|faq)$')
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
icon: str | None = Field(None, max_length=50)
|
||||
replaces_tab: str | None = Field(None, pattern=r'^(faq|rules|privacy|offer)$')
|
||||
|
||||
|
||||
class ReorderItem(BaseModel):
|
||||
"""Single item in a reorder request."""
|
||||
|
||||
id: int
|
||||
sort_order: int = Field(ge=0)
|
||||
|
||||
|
||||
class ReorderRequest(BaseModel):
|
||||
"""Request to bulk-reorder info pages."""
|
||||
|
||||
items: list[ReorderItem] = Field(..., min_length=1)
|
||||
@@ -117,6 +117,8 @@ class TariffDetailResponse(BaseModel):
|
||||
external_squad_uuid: str | None = None
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool = True
|
||||
# Бонусные дни для системы Tasks (при награде subscription_days)
|
||||
bonus_days_per_purchase: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -175,6 +177,8 @@ class TariffCreateRequest(BaseModel):
|
||||
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool = True
|
||||
# Бонусные дни для Tasks (subscription_days reward)
|
||||
bonus_days_per_purchase: int = Field(0, ge=0)
|
||||
|
||||
|
||||
class TariffUpdateRequest(BaseModel):
|
||||
@@ -216,6 +220,8 @@ class TariffUpdateRequest(BaseModel):
|
||||
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool | None = None
|
||||
# Бонусные дни для Tasks (subscription_days reward)
|
||||
bonus_days_per_purchase: int | None = Field(None, ge=0)
|
||||
|
||||
|
||||
class TariffSortOrderRequest(BaseModel):
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Pydantic schemas для системы заданий с наградами."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task partner channels (admin)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TaskPartnerChannelBase(BaseModel):
|
||||
channel_id: str = Field(min_length=1, max_length=100)
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
channel_link: str | None = Field(default=None, max_length=500)
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class TaskPartnerChannelCreateRequest(TaskPartnerChannelBase):
|
||||
pass
|
||||
|
||||
|
||||
class TaskPartnerChannelUpdateRequest(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
channel_link: str | None = Field(default=None, max_length=500)
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
class TaskPartnerChannelResponse(TaskPartnerChannelBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tasks (admin)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
TASK_TYPES: tuple[str, ...] = (
|
||||
'purchase_tariff',
|
||||
'subscribe_channel',
|
||||
'traffic_used',
|
||||
'referrals_invited',
|
||||
'purchase_period',
|
||||
'spend_amount',
|
||||
'multi_tariff',
|
||||
'gift_purchased',
|
||||
'gifts_count',
|
||||
)
|
||||
|
||||
REWARD_TYPES: tuple[str, ...] = ('balance', 'subscription_days')
|
||||
|
||||
USER_AUDIENCES: tuple[str, ...] = ('telegram', 'email', 'both')
|
||||
|
||||
|
||||
class TaskCreateRequest(BaseModel):
|
||||
"""Создание шаблона задания."""
|
||||
|
||||
title: dict[str, str] = Field(..., description='i18n: { "ru": "...", "en": "..." }')
|
||||
description: dict[str, str] = Field(default_factory=dict)
|
||||
icon: str | None = None
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
task_type: str = Field(...)
|
||||
target_value: int = Field(default=1, ge=1)
|
||||
target_meta: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
reward_type: str = Field(...)
|
||||
reward_value: int = Field(default=0, ge=0)
|
||||
reward_meta: dict[str, Any] = Field(default_factory=dict)
|
||||
allow_user_choice: bool = False
|
||||
|
||||
user_audience: str = Field(default='both')
|
||||
promo_group_id: int | None = None
|
||||
|
||||
parent_task_id: int | None = None
|
||||
level: int = Field(default=1, ge=1)
|
||||
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
|
||||
@field_validator('task_type')
|
||||
@classmethod
|
||||
def _validate_task_type(cls, v: str) -> str:
|
||||
if v not in TASK_TYPES:
|
||||
raise ValueError(f'invalid task_type: {v}')
|
||||
return v
|
||||
|
||||
@field_validator('reward_type')
|
||||
@classmethod
|
||||
def _validate_reward_type(cls, v: str) -> str:
|
||||
if v not in REWARD_TYPES:
|
||||
raise ValueError(f'invalid reward_type: {v}')
|
||||
return v
|
||||
|
||||
@field_validator('user_audience')
|
||||
@classmethod
|
||||
def _validate_user_audience(cls, v: str) -> str:
|
||||
if v not in USER_AUDIENCES:
|
||||
raise ValueError(f'invalid user_audience: {v}')
|
||||
return v
|
||||
|
||||
@field_validator('title')
|
||||
@classmethod
|
||||
def _validate_title(cls, v: dict[str, str]) -> dict[str, str]:
|
||||
if not v or not any(value.strip() for value in v.values() if isinstance(value, str)):
|
||||
raise ValueError('title must contain at least one non-empty translation')
|
||||
return v
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _validate_meta_per_type(self) -> TaskCreateRequest:
|
||||
"""Per-type validation: target_meta required keys, reward_value sanity."""
|
||||
# PURCHASE_TARIFF требует tariff_id
|
||||
if self.task_type == 'purchase_tariff' and 'tariff_id' not in (self.target_meta or {}):
|
||||
raise ValueError('PURCHASE_TARIFF requires target_meta.tariff_id')
|
||||
# SUBSCRIBE_CHANNEL требует channel_id (строкой, как в TaskPartnerChannel.channel_id)
|
||||
if self.task_type == 'subscribe_channel':
|
||||
channel_id = (self.target_meta or {}).get('channel_id')
|
||||
if channel_id is None:
|
||||
raise ValueError('SUBSCRIBE_CHANNEL requires target_meta.channel_id')
|
||||
if not isinstance(channel_id, str) or not channel_id.strip():
|
||||
raise ValueError('SUBSCRIBE_CHANNEL target_meta.channel_id must be a non-empty string')
|
||||
# PURCHASE_PERIOD требует period_days
|
||||
if self.task_type == 'purchase_period' and 'period_days' not in (self.target_meta or {}):
|
||||
raise ValueError('PURCHASE_PERIOD requires target_meta.period_days')
|
||||
# BALANCE reward требует reward_value > 0
|
||||
if self.reward_type == 'balance' and self.reward_value <= 0:
|
||||
raise ValueError('BALANCE reward requires reward_value > 0')
|
||||
# SUBSCRIPTION_DAYS reward: либо reward_value > 0, либо tariff_id указан
|
||||
if self.reward_type == 'subscription_days':
|
||||
tariff_id = (self.reward_meta or {}).get('tariff_id')
|
||||
if self.reward_value <= 0 and tariff_id is None:
|
||||
raise ValueError(
|
||||
'SUBSCRIPTION_DAYS reward requires reward_value > 0 or '
|
||||
'reward_meta.tariff_id (to use Tariff.bonus_days_per_purchase)'
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class TaskUpdateRequest(BaseModel):
|
||||
"""Частичное обновление задания (все поля опциональны)."""
|
||||
|
||||
title: dict[str, str] | None = None
|
||||
description: dict[str, str] | None = None
|
||||
icon: str | None = None
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
|
||||
task_type: str | None = None
|
||||
target_value: int | None = Field(default=None, ge=1)
|
||||
target_meta: dict[str, Any] | None = None
|
||||
|
||||
reward_type: str | None = None
|
||||
reward_value: int | None = Field(default=None, ge=0)
|
||||
reward_meta: dict[str, Any] | None = None
|
||||
allow_user_choice: bool | None = None
|
||||
|
||||
user_audience: str | None = None
|
||||
promo_group_id: int | None = None
|
||||
|
||||
parent_task_id: int | None = None
|
||||
level: int | None = Field(default=None, ge=1)
|
||||
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
|
||||
@field_validator('task_type')
|
||||
@classmethod
|
||||
def _validate_task_type(cls, v: str | None) -> str | None:
|
||||
if v is not None and v not in TASK_TYPES:
|
||||
raise ValueError(f'invalid task_type: {v}')
|
||||
return v
|
||||
|
||||
@field_validator('reward_type')
|
||||
@classmethod
|
||||
def _validate_reward_type(cls, v: str | None) -> str | None:
|
||||
if v is not None and v not in REWARD_TYPES:
|
||||
raise ValueError(f'invalid reward_type: {v}')
|
||||
return v
|
||||
|
||||
@field_validator('user_audience')
|
||||
@classmethod
|
||||
def _validate_user_audience(cls, v: str | None) -> str | None:
|
||||
if v is not None and v not in USER_AUDIENCES:
|
||||
raise ValueError(f'invalid user_audience: {v}')
|
||||
return v
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
"""Полное представление задания (для админа)."""
|
||||
|
||||
id: int
|
||||
title: dict[str, str]
|
||||
description: dict[str, str]
|
||||
icon: str | None = None
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
task_type: str
|
||||
target_value: int
|
||||
target_meta: dict[str, Any]
|
||||
reward_type: str
|
||||
reward_value: int
|
||||
reward_meta: dict[str, Any]
|
||||
allow_user_choice: bool
|
||||
user_audience: str
|
||||
promo_group_id: int | None = None
|
||||
parent_task_id: int | None = None
|
||||
level: int
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class TaskListItem(BaseModel):
|
||||
"""Компактное представление для списка."""
|
||||
|
||||
id: int
|
||||
title: dict[str, str]
|
||||
icon: str | None = None
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
task_type: str
|
||||
target_value: int
|
||||
target_meta: dict[str, Any] = Field(default_factory=dict)
|
||||
reward_type: str
|
||||
reward_value: int
|
||||
reward_meta: dict[str, Any] = Field(default_factory=dict)
|
||||
user_audience: str
|
||||
promo_group_id: int | None = None
|
||||
parent_task_id: int | None = None
|
||||
level: int
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User-side schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class UserTaskProgressResponse(BaseModel):
|
||||
"""Прогресс пользователя по конкретному заданию."""
|
||||
|
||||
task_id: int
|
||||
title: dict[str, str]
|
||||
description: dict[str, str]
|
||||
icon: str | None = None
|
||||
task_type: str
|
||||
target_value: int
|
||||
target_meta: dict[str, Any] = Field(default_factory=dict)
|
||||
reward_type: str
|
||||
reward_value: int
|
||||
reward_meta: dict[str, Any] = Field(default_factory=dict)
|
||||
allow_user_choice: bool
|
||||
|
||||
level: int
|
||||
parent_task_id: int | None = None
|
||||
|
||||
current_value: int
|
||||
percent: int
|
||||
is_completed: bool
|
||||
is_claimed: bool
|
||||
completed_at: datetime | None = None
|
||||
claimed_at: datetime | None = None
|
||||
reward_granted_meta: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class UserTasksListResponse(BaseModel):
|
||||
"""Список заданий пользователя."""
|
||||
|
||||
items: list[UserTaskProgressResponse]
|
||||
has_unclaimed: bool
|
||||
unclaimed_count: int
|
||||
|
||||
|
||||
class UserTasksAvailabilityResponse(BaseModel):
|
||||
"""Краткая инфа для условного показа вкладки."""
|
||||
|
||||
has_available_tasks: bool
|
||||
unclaimed_count: int
|
||||
|
||||
|
||||
class ClaimRewardRequest(BaseModel):
|
||||
"""Запрос на получение награды."""
|
||||
|
||||
chosen_subscription_id: int | None = Field(
|
||||
default=None,
|
||||
description='Для multi-tariff / subscription_days reward — какой подписке начислить дни',
|
||||
)
|
||||
chosen_reward_type: Literal['balance', 'subscription_days'] | None = Field(
|
||||
default=None,
|
||||
description='Если allow_user_choice=true, юзер может выбрать тип награды',
|
||||
)
|
||||
|
||||
|
||||
class ClaimRewardResponse(BaseModel):
|
||||
"""Результат claim награды."""
|
||||
|
||||
success: bool
|
||||
reward: dict[str, Any]
|
||||
@@ -2,7 +2,50 @@
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
ALLOWED_MEDIA_TYPES = {'photo', 'video', 'document'}
|
||||
MAX_MEDIA_ITEMS = 10
|
||||
|
||||
|
||||
class TicketMediaItem(BaseModel):
|
||||
"""Single media attachment in a ticket message."""
|
||||
|
||||
type: str = Field(..., description='Media type: photo, video, or document')
|
||||
file_id: str = Field(..., max_length=255, description='Telegram file_id')
|
||||
caption: str | None = Field(None, max_length=1000, description='Optional caption')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_type(self) -> 'TicketMediaItem':
|
||||
if self.type not in ALLOWED_MEDIA_TYPES:
|
||||
raise ValueError(f'type must be one of: {sorted(ALLOWED_MEDIA_TYPES)}')
|
||||
return self
|
||||
|
||||
|
||||
def _validate_media_bundle(
|
||||
media_type: str | None,
|
||||
media_file_id: str | None,
|
||||
media_items: list[TicketMediaItem] | None,
|
||||
) -> None:
|
||||
"""Shared validator for media-attached request bodies."""
|
||||
if media_items is not None:
|
||||
if len(media_items) == 0:
|
||||
raise ValueError('media_items must not be empty (send null instead)')
|
||||
if len(media_items) > MAX_MEDIA_ITEMS:
|
||||
raise ValueError(f'media_items cannot exceed {MAX_MEDIA_ITEMS} entries')
|
||||
if media_file_id and media_file_id != media_items[0].file_id:
|
||||
raise ValueError('legacy media_file_id must match media_items[0].file_id')
|
||||
if media_type and media_type != media_items[0].type:
|
||||
raise ValueError('legacy media_type must match media_items[0].type')
|
||||
return
|
||||
|
||||
if media_file_id and not media_type:
|
||||
raise ValueError('media_type is required when media_file_id is provided')
|
||||
if media_type and not media_file_id:
|
||||
raise ValueError('media_file_id is required when media_type is provided')
|
||||
if media_type and media_type not in ALLOWED_MEDIA_TYPES:
|
||||
raise ValueError(f'media_type must be one of: {sorted(ALLOWED_MEDIA_TYPES)}')
|
||||
|
||||
|
||||
class TicketMessageResponse(BaseModel):
|
||||
@@ -15,6 +58,7 @@ class TicketMessageResponse(BaseModel):
|
||||
media_type: str | None = None
|
||||
media_file_id: str | None = None
|
||||
media_caption: str | None = None
|
||||
media_items: list[TicketMediaItem] | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
@@ -69,16 +113,36 @@ class TicketCreateRequest(BaseModel):
|
||||
"""Request to create a new ticket."""
|
||||
|
||||
title: str = Field(..., min_length=3, max_length=255, description='Ticket title')
|
||||
message: str = Field(..., min_length=10, max_length=4000, description='Initial message')
|
||||
message: str = Field(default='', max_length=4000, description='Initial message')
|
||||
media_type: str | None = Field(None, description='Media type: photo, video, document')
|
||||
media_file_id: str | None = Field(None, description='Telegram file_id of uploaded media')
|
||||
media_caption: str | None = Field(None, max_length=1000, description='Media caption')
|
||||
media_items: list[TicketMediaItem] | None = Field(None, description='Multi-media attachments')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_has_content(self) -> 'TicketCreateRequest':
|
||||
_validate_media_bundle(self.media_type, self.media_file_id, self.media_items)
|
||||
has_text = bool(self.message.strip())
|
||||
has_media = bool(self.media_file_id) or bool(self.media_items)
|
||||
if not has_text and not has_media:
|
||||
raise ValueError('message or media is required')
|
||||
return self
|
||||
|
||||
|
||||
class TicketMessageCreateRequest(BaseModel):
|
||||
"""Request to add message to ticket."""
|
||||
|
||||
message: str = Field(..., min_length=1, max_length=4000, description='Message text')
|
||||
message: str = Field(default='', max_length=4000, description='Message text')
|
||||
media_type: str | None = Field(None, description='Media type: photo, video, document')
|
||||
media_file_id: str | None = Field(None, description='Telegram file_id of uploaded media')
|
||||
media_caption: str | None = Field(None, max_length=1000, description='Media caption')
|
||||
media_items: list[TicketMediaItem] | None = Field(None, description='Multi-media attachments')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_has_content(self) -> 'TicketMessageCreateRequest':
|
||||
_validate_media_bundle(self.media_type, self.media_file_id, self.media_items)
|
||||
has_text = bool(self.message.strip())
|
||||
has_media = bool(self.media_file_id) or bool(self.media_items)
|
||||
if not has_text and not has_media:
|
||||
raise ValueError('message or media is required')
|
||||
return self
|
||||
|
||||
@@ -82,6 +82,21 @@ class UserPromoGroupInfo(BaseModel):
|
||||
# === User List ===
|
||||
|
||||
|
||||
class SubscriptionListItem(BaseModel):
|
||||
"""Compact subscription info for user list (multi-tariff mode)."""
|
||||
|
||||
id: int
|
||||
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
|
||||
traffic_limit_gb: int = 0
|
||||
device_limit: int = 0
|
||||
|
||||
|
||||
class UserListItem(BaseModel):
|
||||
"""User item in list."""
|
||||
|
||||
@@ -102,6 +117,15 @@ class UserListItem(BaseModel):
|
||||
subscription_status: str | None = None
|
||||
subscription_is_trial: bool = False
|
||||
subscription_end_date: datetime | None = None
|
||||
tariff_id: int | None = None
|
||||
tariff_name: str | None = None
|
||||
traffic_used_gb: float = 0
|
||||
traffic_limit_gb: int = 0
|
||||
device_limit: int = 0
|
||||
days_remaining: int = 0
|
||||
|
||||
# All subscriptions (multi-tariff)
|
||||
subscriptions: list[SubscriptionListItem] = []
|
||||
|
||||
# Promo group
|
||||
promo_group_id: int | None = None
|
||||
|
||||
@@ -12,7 +12,16 @@ def get_campaign_deep_link(start_parameter: str) -> str:
|
||||
|
||||
|
||||
def get_campaign_web_link(start_parameter: str) -> str | None:
|
||||
"""Generate a web app link for a campaign."""
|
||||
"""Generate a web app link for a campaign.
|
||||
|
||||
Prefers CABINET_URL (where the auth flow captures ?campaign= param),
|
||||
falls back to MINIAPP_CUSTOM_URL for backwards compatibility.
|
||||
"""
|
||||
cabinet_url = settings._normalized_cabinet_url()
|
||||
if cabinet_url:
|
||||
sep = '&' if '?' in cabinet_url else '?'
|
||||
return f'{cabinet_url}{sep}campaign={start_parameter}'
|
||||
|
||||
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
|
||||
if base_url:
|
||||
return f'{base_url}/?campaign={start_parameter}'
|
||||
|
||||
+500
-39
@@ -1,5 +1,3 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
@@ -67,10 +65,22 @@ 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 = 300 # Интервал проверки очереди (секунды)
|
||||
NALOGO_QUEUE_CHECK_INTERVAL: int = 600 # Интервал проверки очереди (секунды, 10 мин)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS: int = 10 # Максимум попыток отправки чека
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS: int = 72 # Максимум попыток отправки чека (72 × 10мин = 12 часов)
|
||||
|
||||
ADMIN_REPORTS_ENABLED: bool = False
|
||||
ADMIN_REPORTS_CHAT_ID: str | None = None
|
||||
@@ -133,6 +143,7 @@ class Settings(BaseSettings):
|
||||
WEBHOOK_NOTIFY_NOT_CONNECTED: bool = True
|
||||
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD: bool = True
|
||||
WEBHOOK_NOTIFY_DEVICES: bool = True
|
||||
WEBHOOK_NOTIFY_TORRENT_DETECTED: bool = True
|
||||
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
@@ -147,6 +158,9 @@ class Settings(BaseSettings):
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = 'MONTH'
|
||||
RESET_TRAFFIC_ON_PAYMENT: bool = False
|
||||
RESET_TRAFFIC_ON_TARIFF_SWITCH: bool = True
|
||||
RESET_DEVICES_ON_RENEWAL: bool = False
|
||||
TARIFF_SWITCH_UPGRADE_ENABLED: bool = True
|
||||
TARIFF_SWITCH_DOWNGRADE_ENABLED: bool = True
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
|
||||
TRIAL_WARNING_HOURS: int = 2
|
||||
@@ -272,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
|
||||
@@ -323,6 +341,7 @@ class Settings(BaseSettings):
|
||||
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS: int = 20000
|
||||
|
||||
MONITORING_INTERVAL: int = 60
|
||||
LOW_BALANCE_ALERT_EXPIRY_DAYS: int = 3 # Only alert when subscription expires within N days
|
||||
INACTIVE_USER_DELETE_MONTHS: int = 3
|
||||
|
||||
MAINTENANCE_MODE: bool = False
|
||||
@@ -435,6 +454,7 @@ class Settings(BaseSettings):
|
||||
MULENPAY_LANGUAGE: str = 'ru'
|
||||
MULENPAY_VAT_CODE: int = 0
|
||||
|
||||
DISPLAY_NAME_RESTRICTION_ENABLED: bool = True
|
||||
DISPLAY_NAME_BANNED_KEYWORDS: str = '\n'.join(DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS)
|
||||
MULENPAY_PAYMENT_SUBJECT: int = 4
|
||||
MULENPAY_PAYMENT_MODE: int = 4
|
||||
@@ -561,6 +581,21 @@ class Settings(BaseSettings):
|
||||
KASSA_AI_SBERPAY_ENABLED: bool = False # SberPay — payment_system_id=43
|
||||
KASSA_AI_SBERPAY_DISPLAY_NAME: str = 'SberPay (KassaAI)'
|
||||
|
||||
# ── Yandex Metrika offline conversions (server → mc.yandex.ru/collect) ──
|
||||
YANDEX_OFFLINE_CONV_ENABLED: bool = False
|
||||
YANDEX_OFFLINE_CONV_COUNTER_ID: str = ''
|
||||
YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET: str = ''
|
||||
YANDEX_OFFLINE_CONV_START_PREFIX: str = 'utm_ya_'
|
||||
YANDEX_OFFLINE_CONV_DL: str = ''
|
||||
YANDEX_OFFLINE_CONV_DT: str = ''
|
||||
YANDEX_OFFLINE_CONV_CURRENCY: str = 'RUB'
|
||||
|
||||
# ── S2S Postback (server-to-server affiliate notifications) ──
|
||||
S2S_POSTBACK_ENABLED: bool = False
|
||||
S2S_POSTBACK_REGISTRATION_URL: str = ''
|
||||
S2S_POSTBACK_TRIAL_URL: str = ''
|
||||
S2S_POSTBACK_PURCHASE_URL: str = ''
|
||||
|
||||
# RioPay (api.riopay.online) v2.0.1
|
||||
RIOPAY_ENABLED: bool = False
|
||||
RIOPAY_API_TOKEN: str | None = None # x-api-token header
|
||||
@@ -585,6 +620,174 @@ 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
|
||||
PAYPEAR_SECRET_KEY: str | None = None
|
||||
PAYPEAR_DISPLAY_NAME: str = 'PayPear'
|
||||
PAYPEAR_CURRENCY: str = 'RUB'
|
||||
PAYPEAR_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
PAYPEAR_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
PAYPEAR_WEBHOOK_PATH: str = '/paypear-webhook'
|
||||
PAYPEAR_RETURN_URL: str | None = None
|
||||
PAYPEAR_PAYMENT_METHOD: str = 'sbp' # bank_card, sbp, sberpay, tpay
|
||||
|
||||
# RollyPay (rollypay.io)
|
||||
ROLLYPAY_ENABLED: bool = False
|
||||
ROLLYPAY_API_KEY: str | None = None # X-API-Key header
|
||||
ROLLYPAY_SIGNING_SECRET: str | None = None # HMAC webhook verification
|
||||
ROLLYPAY_DISPLAY_NAME: str = 'RollyPay'
|
||||
ROLLYPAY_CURRENCY: str = 'RUB'
|
||||
ROLLYPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
ROLLYPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
ROLLYPAY_WEBHOOK_PATH: str = '/rollypay-webhook'
|
||||
ROLLYPAY_RETURN_URL: str | None = None
|
||||
|
||||
# Overpay (pay.overpay.io)
|
||||
OVERPAY_ENABLED: bool = False
|
||||
OVERPAY_API_URL: str = 'https://api.overpay.io'
|
||||
OVERPAY_USERNAME: str | None = None
|
||||
OVERPAY_PASSWORD: str | None = None
|
||||
OVERPAY_PROJECT_ID: str | None = None
|
||||
OVERPAY_P12_PATH: str | None = None
|
||||
OVERPAY_P12_PASSPHRASE: str | None = None
|
||||
OVERPAY_DISPLAY_NAME: str = 'Overpay'
|
||||
OVERPAY_CURRENCY: str = 'RUB'
|
||||
OVERPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
OVERPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||
OVERPAY_WEBHOOK_PATH: str = '/overpay-webhook'
|
||||
OVERPAY_RETURN_URL: str | None = None
|
||||
OVERPAY_LIFETIME_MINUTES: int = 1440
|
||||
OVERPAY_PAYMENT_METHODS: str = 'card,fps'
|
||||
|
||||
# AuraPay (aurapay.tech)
|
||||
AURAPAY_ENABLED: bool = False
|
||||
AURAPAY_API_KEY: str | None = None # X-ApiKey header
|
||||
AURAPAY_SHOP_ID: str | None = None # X-ShopId header (UUID)
|
||||
AURAPAY_SECRET_KEY: str | None = None # Secret key #2 for webhook HMAC
|
||||
AURAPAY_DISPLAY_NAME: str = 'AuraPay'
|
||||
AURAPAY_CURRENCY: str = 'RUB'
|
||||
AURAPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
AURAPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
AURAPAY_WEBHOOK_PATH: str = '/aurapay-webhook'
|
||||
AURAPAY_RETURN_URL: str | None = None
|
||||
AURAPAY_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
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 (красный), '' (по умолчанию для каждой секции)
|
||||
CABINET_BUTTON_STYLE: str = ''
|
||||
@@ -729,6 +932,7 @@ class Settings(BaseSettings):
|
||||
WEBHOOK_URL: str | None = None
|
||||
WEBHOOK_PATH: str = '/webhook'
|
||||
WEBHOOK_SECRET_TOKEN: str | None = None
|
||||
WEBHOOK_IP: str | None = None # IP адрес для setWebhook, чтобы Telegram не резолвил домен
|
||||
WEBHOOK_DROP_PENDING_UPDATES: bool = True
|
||||
WEBHOOK_MAX_QUEUE_SIZE: int = 1024
|
||||
WEBHOOK_WORKERS: int = 4
|
||||
@@ -769,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
|
||||
@@ -824,6 +1025,10 @@ class Settings(BaseSettings):
|
||||
# Format: socks5://user:password@host:port or socks5://host:port
|
||||
PROXY_URL: str | None = None
|
||||
|
||||
# Custom Telegram Bot API server URL (for regions where api.telegram.org is blocked)
|
||||
# Examples: Cloudflare Worker proxy, self-hosted telegram-bot-api (tdlib), nginx reverse proxy
|
||||
TELEGRAM_API_URL: str | None = None
|
||||
|
||||
@field_validator('PROXY_URL', 'NALOGO_PROXY_URL', mode='before')
|
||||
@classmethod
|
||||
def validate_proxy_url(cls, value: str | None) -> str | None:
|
||||
@@ -971,6 +1176,10 @@ class Settings(BaseSettings):
|
||||
"""Return SOCKS5 proxy URL or None."""
|
||||
return self.PROXY_URL if self.PROXY_URL else None
|
||||
|
||||
def get_telegram_api_url(self) -> str | None:
|
||||
"""Return custom Telegram Bot API server URL or None."""
|
||||
return self.TELEGRAM_API_URL if self.TELEGRAM_API_URL else None
|
||||
|
||||
def get_nalogo_proxy_url(self) -> str | None:
|
||||
"""Return SOCKS proxy URL for nalogo or None.
|
||||
|
||||
@@ -1184,7 +1393,13 @@ class Settings(BaseSettings):
|
||||
if not sanitized_username:
|
||||
sanitized_username = _sanitize(f'user_{identifier}')
|
||||
|
||||
return sanitized_username[:36].strip('_-') or 'user'
|
||||
result = sanitized_username[:36].strip('_-') or 'user'
|
||||
|
||||
# RemnaWave требует username минимум 3 символа
|
||||
if len(result) < 3:
|
||||
result = f'{result}_{identifier}'[:36].strip('_-')
|
||||
|
||||
return result or 'user'
|
||||
|
||||
@staticmethod
|
||||
def parse_daily_time_list(raw_value: str | None) -> list[time]:
|
||||
@@ -1564,37 +1779,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'
|
||||
|
||||
@@ -1683,6 +1867,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'
|
||||
@@ -1962,6 +2150,279 @@ 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
|
||||
|
||||
def get_paypear_display_name(self) -> str:
|
||||
name = (self.PAYPEAR_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'PayPear'
|
||||
|
||||
def get_paypear_display_name_html(self) -> str:
|
||||
return html.escape(self.get_paypear_display_name())
|
||||
|
||||
def is_rollypay_enabled(self) -> bool:
|
||||
return self.ROLLYPAY_ENABLED and self.ROLLYPAY_API_KEY is not None and self.ROLLYPAY_SIGNING_SECRET is not None
|
||||
|
||||
def get_rollypay_display_name(self) -> str:
|
||||
name = (self.ROLLYPAY_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'RollyPay'
|
||||
|
||||
def get_rollypay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_rollypay_display_name())
|
||||
|
||||
def is_overpay_enabled(self) -> bool:
|
||||
return (
|
||||
self.OVERPAY_ENABLED
|
||||
and self.OVERPAY_USERNAME is not None
|
||||
and self.OVERPAY_PASSWORD is not None
|
||||
and self.OVERPAY_PROJECT_ID is not None
|
||||
)
|
||||
|
||||
def get_overpay_display_name(self) -> str:
|
||||
name = (self.OVERPAY_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Overpay'
|
||||
|
||||
def get_overpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_overpay_display_name())
|
||||
|
||||
def is_aurapay_enabled(self) -> bool:
|
||||
return (
|
||||
self.AURAPAY_ENABLED
|
||||
and self.AURAPAY_API_KEY is not None
|
||||
and self.AURAPAY_SHOP_ID is not None
|
||||
and self.AURAPAY_SECRET_KEY is not None
|
||||
)
|
||||
|
||||
def get_aurapay_display_name(self) -> str:
|
||||
name = (self.AURAPAY_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'AuraPay'
|
||||
|
||||
def get_aurapay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_aurapay_display_name())
|
||||
|
||||
def is_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,157 @@
|
||||
"""CRUD операции для платежей AuraPay."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import AuraPayPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_aurapay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
aurapay_invoice_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> AuraPayPayment:
|
||||
"""Создает запись о платеже AuraPay."""
|
||||
payment = AuraPayPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
aurapay_invoice_id=aurapay_invoice_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж AuraPay', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_aurapay_payment_by_order_id(db: AsyncSession, order_id: str) -> AuraPayPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(AuraPayPayment).where(AuraPayPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_aurapay_payment_by_invoice_id(db: AsyncSession, aurapay_invoice_id: str) -> AuraPayPayment | None:
|
||||
"""Получает платеж по UUID от AuraPay."""
|
||||
result = await db.execute(select(AuraPayPayment).where(AuraPayPayment.aurapay_invoice_id == aurapay_invoice_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_aurapay_payment_by_id(db: AsyncSession, payment_id: int) -> AuraPayPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(AuraPayPayment).where(AuraPayPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_aurapay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> AuraPayPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(AuraPayPayment)
|
||||
.where(AuraPayPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_aurapay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: AuraPayPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
aurapay_invoice_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> AuraPayPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if aurapay_invoice_id is not None:
|
||||
payment.aurapay_invoice_id = aurapay_invoice_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлен статус платежа AuraPay',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_aurapay_payments(db: AsyncSession, user_id: int) -> list[AuraPayPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(AuraPayPayment).where(
|
||||
AuraPayPayment.user_id == user_id,
|
||||
AuraPayPayment.status == 'pending',
|
||||
AuraPayPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_aurapay_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[AuraPayPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(AuraPayPayment).where(
|
||||
AuraPayPayment.status == 'pending',
|
||||
AuraPayPayment.is_paid == False,
|
||||
AuraPayPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_aurapay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: AuraPayPayment,
|
||||
transaction_id: int,
|
||||
) -> AuraPayPayment:
|
||||
"""Связывает платеж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -0,0 +1,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,214 @@
|
||||
"""CRUD operations for info pages."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import InfoPage
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Fields that can be set via update_info_page
|
||||
_ALLOWED_UPDATE_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
'slug',
|
||||
'title',
|
||||
'content',
|
||||
'page_type',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
'icon',
|
||||
'replaces_tab',
|
||||
}
|
||||
)
|
||||
|
||||
# Fields that can be explicitly set to None
|
||||
_NULLABLE_UPDATE_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
'icon',
|
||||
'replaces_tab',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def create_info_page(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
slug: str,
|
||||
title: dict[str, str],
|
||||
content: dict[str, str],
|
||||
page_type: str = 'page',
|
||||
is_active: bool = True,
|
||||
sort_order: int = 0,
|
||||
icon: str | None = None,
|
||||
replaces_tab: str | None = None,
|
||||
) -> InfoPage:
|
||||
"""Create a new info page.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if slug is not unique (caller must handle).
|
||||
"""
|
||||
page = InfoPage(
|
||||
slug=slug,
|
||||
title=title,
|
||||
content=content,
|
||||
page_type=page_type,
|
||||
is_active=is_active,
|
||||
sort_order=sort_order,
|
||||
icon=icon,
|
||||
replaces_tab=replaces_tab,
|
||||
)
|
||||
|
||||
db.add(page)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info('Created info page', page_id=page.id, slug=page.slug)
|
||||
return page
|
||||
|
||||
|
||||
async def get_info_page_by_id(db: AsyncSession, page_id: int) -> InfoPage | None:
|
||||
"""Get an info page by ID."""
|
||||
result = await db.execute(select(InfoPage).where(InfoPage.id == page_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_info_page_by_slug(db: AsyncSession, slug: str) -> InfoPage | None:
|
||||
"""Get an info page by slug."""
|
||||
result = await db.execute(select(InfoPage).where(InfoPage.slug == slug))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_all_info_pages(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
page_type: str | None = None,
|
||||
) -> list[InfoPage]:
|
||||
"""Get all info pages, ordered by sort_order ascending."""
|
||||
stmt = select(InfoPage)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(InfoPage.is_active.is_(True))
|
||||
if page_type is not None:
|
||||
stmt = stmt.where(InfoPage.page_type == page_type)
|
||||
|
||||
stmt = stmt.order_by(InfoPage.sort_order.asc(), InfoPage.id.asc())
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_info_page(
|
||||
db: AsyncSession,
|
||||
page_id: int,
|
||||
**kwargs: Any,
|
||||
) -> InfoPage | None:
|
||||
"""Update an info page. Only whitelisted fields are applied.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if slug conflicts with another page (caller must handle).
|
||||
"""
|
||||
update_data: dict[str, Any] = {}
|
||||
for key, value in kwargs.items():
|
||||
if key not in _ALLOWED_UPDATE_FIELDS:
|
||||
continue
|
||||
if value is None and key not in _NULLABLE_UPDATE_FIELDS:
|
||||
continue
|
||||
update_data[key] = value
|
||||
|
||||
if not update_data:
|
||||
return await get_info_page_by_id(db, page_id)
|
||||
|
||||
update_data['updated_at'] = datetime.now(UTC)
|
||||
|
||||
await db.execute(update(InfoPage).where(InfoPage.id == page_id).values(**update_data))
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
page = await get_info_page_by_id(db, page_id)
|
||||
if page:
|
||||
logger.info(
|
||||
'Updated info page',
|
||||
page_id=page_id,
|
||||
updated_fields=list(update_data.keys()),
|
||||
)
|
||||
return page
|
||||
|
||||
|
||||
async def delete_info_page(db: AsyncSession, page_id: int) -> None:
|
||||
"""Delete an info page."""
|
||||
await db.execute(delete(InfoPage).where(InfoPage.id == page_id))
|
||||
await db.commit()
|
||||
|
||||
logger.info('Deleted info page', page_id=page_id)
|
||||
|
||||
|
||||
async def get_tab_replacements(db: AsyncSession) -> dict[str, str | None]:
|
||||
"""Return a mapping of tab name to info page slug for active pages with replaces_tab set.
|
||||
|
||||
Returns dict like ``{'faq': 'my-custom-faq', 'rules': None, 'privacy': None, 'offer': None}``.
|
||||
"""
|
||||
result_map: dict[str, str | None] = {
|
||||
'faq': None,
|
||||
'rules': None,
|
||||
'privacy': None,
|
||||
'offer': None,
|
||||
}
|
||||
|
||||
stmt = select(InfoPage).where(
|
||||
InfoPage.is_active.is_(True),
|
||||
InfoPage.replaces_tab.isnot(None),
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
for page in result.scalars().all():
|
||||
if page.replaces_tab in result_map:
|
||||
result_map[page.replaces_tab] = page.slug
|
||||
|
||||
return result_map
|
||||
|
||||
|
||||
async def clear_replaces_tab(db: AsyncSession, tab: str, *, exclude_page_id: int | None = None) -> None:
|
||||
"""Clear replaces_tab for all pages that currently replace the given tab.
|
||||
|
||||
Optionally exclude a specific page (the one being saved).
|
||||
"""
|
||||
stmt = (
|
||||
update(InfoPage)
|
||||
.where(
|
||||
InfoPage.replaces_tab == tab,
|
||||
)
|
||||
.values(replaces_tab=None, updated_at=datetime.now(UTC))
|
||||
)
|
||||
if exclude_page_id is not None:
|
||||
stmt = stmt.where(InfoPage.id != exclude_page_id)
|
||||
|
||||
await db.execute(stmt)
|
||||
|
||||
|
||||
async def reorder_info_pages(db: AsyncSession, items: list[dict]) -> None:
|
||||
"""Bulk update sort_order for info pages.
|
||||
|
||||
Each item must have ``id`` and ``sort_order`` attributes.
|
||||
"""
|
||||
for item in items:
|
||||
page_id = item.id if hasattr(item, 'id') else item.get('id')
|
||||
sort_order = item.sort_order if hasattr(item, 'sort_order') else item.get('sort_order')
|
||||
if page_id is None or sort_order is None:
|
||||
continue
|
||||
await db.execute(
|
||||
update(InfoPage).where(InfoPage.id == page_id).values(sort_order=sort_order, updated_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info('Reordered info pages', count=len(items))
|
||||
@@ -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
|
||||
@@ -76,6 +76,11 @@ _LANDING_UPDATABLE_FIELDS = frozenset(
|
||||
'discount_ends_at',
|
||||
'discount_badge_text',
|
||||
'background_config',
|
||||
'sticky_pay_button',
|
||||
'analytics_view_enabled',
|
||||
'analytics_view_goal',
|
||||
'analytics_click_enabled',
|
||||
'analytics_click_goal',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,157 @@
|
||||
"""CRUD операции для платежей Overpay."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import OverpayPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_overpay_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,
|
||||
overpay_payment_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> OverpayPayment:
|
||||
"""Создает запись о платеже Overpay."""
|
||||
payment = OverpayPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
overpay_payment_id=overpay_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('Создан платеж Overpay', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_overpay_payment_by_order_id(db: AsyncSession, order_id: str) -> OverpayPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(OverpayPayment).where(OverpayPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_overpay_payment_by_overpay_id(db: AsyncSession, overpay_payment_id: str) -> OverpayPayment | None:
|
||||
"""Получает платеж по ID от Overpay."""
|
||||
result = await db.execute(select(OverpayPayment).where(OverpayPayment.overpay_payment_id == overpay_payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_overpay_payment_by_id(db: AsyncSession, payment_id: int) -> OverpayPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(OverpayPayment).where(OverpayPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_overpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> OverpayPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(OverpayPayment)
|
||||
.where(OverpayPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_overpay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: OverpayPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
overpay_payment_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> OverpayPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
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 overpay_payment_id is not None:
|
||||
payment.overpay_payment_id = overpay_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(
|
||||
'Обновлен статус платежа Overpay',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_overpay_payments(db: AsyncSession, user_id: int) -> list[OverpayPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(OverpayPayment).where(
|
||||
OverpayPayment.user_id == user_id,
|
||||
OverpayPayment.status == 'pending',
|
||||
OverpayPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_overpay_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[OverpayPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(OverpayPayment).where(
|
||||
OverpayPayment.status == 'pending',
|
||||
OverpayPayment.is_paid == False,
|
||||
OverpayPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_overpay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: OverpayPayment,
|
||||
transaction_id: int,
|
||||
) -> OverpayPayment:
|
||||
"""Связывает платеж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -0,0 +1,157 @@
|
||||
"""CRUD операции для платежей PayPear."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PayPearPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_paypear_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
paypear_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> PayPearPayment:
|
||||
"""Создает запись о платеже PayPear."""
|
||||
payment = PayPearPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
paypear_id=paypear_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж PayPear', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_paypear_payment_by_order_id(db: AsyncSession, order_id: str) -> PayPearPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(PayPearPayment).where(PayPearPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_paypear_payment_by_paypear_id(db: AsyncSession, paypear_id: str) -> PayPearPayment | None:
|
||||
"""Получает платеж по ID от PayPear."""
|
||||
result = await db.execute(select(PayPearPayment).where(PayPearPayment.paypear_id == paypear_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_paypear_payment_by_id(db: AsyncSession, payment_id: int) -> PayPearPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(PayPearPayment).where(PayPearPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_paypear_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> PayPearPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(PayPearPayment)
|
||||
.where(PayPearPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_paypear_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: PayPearPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
paypear_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> PayPearPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if paypear_id is not None:
|
||||
payment.paypear_id = paypear_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлен статус платежа PayPear',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_paypear_payments(db: AsyncSession, user_id: int) -> list[PayPearPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(PayPearPayment).where(
|
||||
PayPearPayment.user_id == user_id,
|
||||
PayPearPayment.status == 'pending',
|
||||
PayPearPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_paypear_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[PayPearPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(PayPearPayment).where(
|
||||
PayPearPayment.status == 'pending',
|
||||
PayPearPayment.is_paid == False,
|
||||
PayPearPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_paypear_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: PayPearPayment,
|
||||
transaction_id: int,
|
||||
) -> PayPearPayment:
|
||||
"""Связывает платеж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -166,7 +166,7 @@ async def update_promo_group(
|
||||
group.device_discount_percent = max(0, min(100, device_discount_percent))
|
||||
if period_discounts is not None:
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
group.period_discounts = normalized_period_discounts or None
|
||||
group.period_discounts = normalized_period_discounts if normalized_period_discounts else None
|
||||
if auto_assign_total_spent_kopeks is not None:
|
||||
value = max(0, auto_assign_total_spent_kopeks)
|
||||
group.auto_assign_total_spent_kopeks = value if value > 0 else None
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""CRUD операции для платежей RollyPay."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import RollyPayPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_rollypay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
rollypay_payment_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> RollyPayPayment:
|
||||
"""Создает запись о платеже RollyPay."""
|
||||
payment = RollyPayPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
rollypay_payment_id=rollypay_payment_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж RollyPay', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_rollypay_payment_by_order_id(db: AsyncSession, order_id: str) -> RollyPayPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(RollyPayPayment).where(RollyPayPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_rollypay_payment_by_rollypay_id(db: AsyncSession, rollypay_payment_id: str) -> RollyPayPayment | None:
|
||||
"""Получает платеж по ID от RollyPay."""
|
||||
result = await db.execute(select(RollyPayPayment).where(RollyPayPayment.rollypay_payment_id == rollypay_payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_rollypay_payment_by_id(db: AsyncSession, payment_id: int) -> RollyPayPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(RollyPayPayment).where(RollyPayPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_rollypay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> RollyPayPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(RollyPayPayment)
|
||||
.where(RollyPayPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_rollypay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: RollyPayPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
rollypay_payment_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> RollyPayPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if rollypay_payment_id is not None:
|
||||
payment.rollypay_payment_id = rollypay_payment_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлен статус платежа RollyPay',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_rollypay_payments(db: AsyncSession, user_id: int) -> list[RollyPayPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(RollyPayPayment).where(
|
||||
RollyPayPayment.user_id == user_id,
|
||||
RollyPayPayment.status == 'pending',
|
||||
RollyPayPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_rollypay_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[RollyPayPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(RollyPayPayment).where(
|
||||
RollyPayPayment.status == 'pending',
|
||||
RollyPayPayment.is_paid == False,
|
||||
RollyPayPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_rollypay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: RollyPayPayment,
|
||||
transaction_id: int,
|
||||
) -> RollyPayPayment:
|
||||
"""Связывает платеж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -33,7 +33,15 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
async def _get_default_promo_group_id(db: AsyncSession) -> int | None:
|
||||
result = await db.execute(select(PromoGroup.id).where(PromoGroup.is_default.is_(True)).limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
default_id = result.scalar_one_or_none()
|
||||
if default_id is not None:
|
||||
return default_id
|
||||
|
||||
# На пустой БД дефолтной промогруппы нет — создаём автоматически
|
||||
from app.database.crud.user import _get_or_create_default_promo_group
|
||||
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
return default_group.id
|
||||
|
||||
|
||||
async def create_server_squad(
|
||||
@@ -155,6 +163,20 @@ async def get_available_server_squads(
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def get_effective_tariff_squad_uuids(
|
||||
db: AsyncSession,
|
||||
allowed_squads: Sequence[str] | None,
|
||||
) -> list[str]:
|
||||
"""Resolve tariff squads, treating an empty list as "all available squads"."""
|
||||
|
||||
normalized = [str(squad_uuid) for squad_uuid in (allowed_squads or []) if squad_uuid]
|
||||
if normalized:
|
||||
return list(dict.fromkeys(normalized))
|
||||
|
||||
available = await get_available_server_squads(db)
|
||||
return [squad.squad_uuid for squad in available if squad.squad_uuid]
|
||||
|
||||
|
||||
async def get_active_server_squads(db: AsyncSession) -> list[ServerSquad]:
|
||||
"""Возвращает список активных серверов, доступных для подключения."""
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import math
|
||||
import secrets
|
||||
from collections.abc import Iterable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -96,12 +97,13 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscri
|
||||
)
|
||||
.where(Subscription.user_id == user_id)
|
||||
.order_by(
|
||||
# Active/trial subscriptions first, then by creation date
|
||||
# Active/trial subscriptions first, then by end_date (most remaining time)
|
||||
case(
|
||||
(Subscription.status == SubscriptionStatus.ACTIVE.value, 0),
|
||||
(Subscription.status == SubscriptionStatus.TRIAL.value, 1),
|
||||
else_=2,
|
||||
),
|
||||
Subscription.end_date.desc().nulls_last(),
|
||||
Subscription.created_at.desc(),
|
||||
)
|
||||
.limit(1)
|
||||
@@ -141,8 +143,8 @@ async def create_trial_subscription(
|
||||
if device_limit is None:
|
||||
device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
|
||||
# Если переданы connected_squads, используем их
|
||||
# Иначе используем squad_uuid или получаем случайный
|
||||
# Если переданы connected_squads, используем их.
|
||||
# Иначе используем squad_uuid или все доступные сквады по умолчанию.
|
||||
final_squads = []
|
||||
if connected_squads:
|
||||
final_squads = connected_squads
|
||||
@@ -150,13 +152,14 @@ async def create_trial_subscription(
|
||||
final_squads = [squad_uuid]
|
||||
else:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
|
||||
random_squad = await get_random_trial_squad_uuid(db)
|
||||
if random_squad:
|
||||
final_squads = [random_squad]
|
||||
final_squads = await get_effective_tariff_squad_uuids(db, None)
|
||||
if final_squads:
|
||||
logger.debug(
|
||||
'Выбран сквад для триальной подписки пользователя', random_squad=random_squad, user_id=user_id
|
||||
'Выбраны дефолтные сквады для триальной подписки пользователя',
|
||||
final_squads=final_squads,
|
||||
user_id=user_id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Не удалось получить сквад для триальной подписки пользователя', user_id=user_id, error=error)
|
||||
@@ -1294,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
|
||||
@@ -2075,7 +2078,12 @@ async def toggle_daily_subscription_pause(
|
||||
|
||||
|
||||
async def get_active_subscriptions_by_user_id(db: AsyncSession, user_id: int) -> list[Subscription]:
|
||||
"""Get all active/trial subscriptions for a user."""
|
||||
"""Get all active/trial/limited subscriptions for a user.
|
||||
|
||||
Includes LIMITED status because those subscriptions still have time remaining
|
||||
(just ran out of traffic) and should be treated as "alive" for renewal,
|
||||
duplicate prevention, and display purposes.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
@@ -2084,7 +2092,13 @@ async def get_active_subscriptions_by_user_id(db: AsyncSession, user_id: int) ->
|
||||
)
|
||||
.where(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
)
|
||||
@@ -2121,7 +2135,11 @@ async def get_subscription_by_id(db: AsyncSession, subscription_id: int) -> Subs
|
||||
|
||||
|
||||
async def get_subscription_by_user_and_tariff(db: AsyncSession, user_id: int, tariff_id: int) -> Subscription | None:
|
||||
"""Get active/trial subscription for a specific user+tariff combination."""
|
||||
"""Get active/trial/limited subscription for a specific user+tariff combination.
|
||||
|
||||
Includes LIMITED status because those subscriptions still have time remaining
|
||||
(just ran out of traffic) and should be extended rather than duplicated.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
@@ -2131,7 +2149,13 @@ async def get_subscription_by_user_and_tariff(db: AsyncSession, user_id: int, ta
|
||||
.where(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
.limit(1)
|
||||
|
||||
@@ -122,6 +122,12 @@ async def clear_trial_tariff(db: AsyncSession) -> None:
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_all_active_tariffs(db: AsyncSession) -> list[Tariff]:
|
||||
"""Get all active tariffs."""
|
||||
result = await db.execute(select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.tier_level))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_tariffs_for_user(
|
||||
db: AsyncSession,
|
||||
promo_group_id: int | None = None,
|
||||
@@ -194,6 +200,8 @@ async def create_tariff(
|
||||
traffic_reset_mode: str | None = None, # DAY, WEEK, MONTH, MONTH_ROLLING, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = None,
|
||||
# Бонусные дни для Tasks (subscription_days reward)
|
||||
bonus_days_per_purchase: int = 0,
|
||||
) -> Tariff:
|
||||
"""Создает новый тариф."""
|
||||
normalized_prices = _normalize_period_prices(period_prices)
|
||||
@@ -234,6 +242,8 @@ async def create_tariff(
|
||||
traffic_reset_mode=traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
external_squad_uuid=external_squad_uuid,
|
||||
# Бонусные дни Tasks
|
||||
bonus_days_per_purchase=max(0, bonus_days_per_purchase),
|
||||
)
|
||||
|
||||
db.add(tariff)
|
||||
@@ -303,6 +313,8 @@ async def update_tariff(
|
||||
traffic_reset_mode: str | None = ..., # ... = не передан, None = сбросить к глобальной настройке
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = ..., # ... = не передан, None = убрать внешний сквад
|
||||
# Бонусные дни для Tasks
|
||||
bonus_days_per_purchase: int | None = None,
|
||||
) -> Tariff:
|
||||
"""Обновляет существующий тариф."""
|
||||
if name is not None:
|
||||
@@ -372,6 +384,9 @@ async def update_tariff(
|
||||
# Внешний сквад
|
||||
if external_squad_uuid is not ...:
|
||||
tariff.external_squad_uuid = external_squad_uuid
|
||||
# Бонусные дни Tasks
|
||||
if bonus_days_per_purchase is not None:
|
||||
tariff.bonus_days_per_purchase = max(0, bonus_days_per_purchase)
|
||||
|
||||
# Обновляем промогруппы если указаны
|
||||
if promo_group_ids is not None:
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
"""CRUD операции для системы заданий с наградами."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Task,
|
||||
TaskPartnerChannel,
|
||||
TaskRewardType,
|
||||
TaskType,
|
||||
TaskUserAudience,
|
||||
UserTaskProgress,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Task CRUD
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
async def create_task(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
title: dict[str, str],
|
||||
description: dict[str, str],
|
||||
task_type: TaskType | str,
|
||||
reward_type: TaskRewardType | str,
|
||||
target_value: int = 1,
|
||||
reward_value: int = 0,
|
||||
target_meta: dict[str, Any] | None = None,
|
||||
reward_meta: dict[str, Any] | None = None,
|
||||
icon: str | None = None,
|
||||
is_active: bool = True,
|
||||
sort_order: int = 0,
|
||||
allow_user_choice: bool = False,
|
||||
user_audience: TaskUserAudience | str = TaskUserAudience.BOTH,
|
||||
promo_group_id: int | None = None,
|
||||
parent_task_id: int | None = None,
|
||||
level: int = 1,
|
||||
starts_at: datetime | None = None,
|
||||
ends_at: datetime | None = None,
|
||||
) -> Task:
|
||||
"""Создаёт новый шаблон задания."""
|
||||
task = Task(
|
||||
title=title,
|
||||
description=description,
|
||||
icon=icon,
|
||||
is_active=is_active,
|
||||
sort_order=sort_order,
|
||||
task_type=task_type.value if isinstance(task_type, TaskType) else task_type,
|
||||
target_value=target_value,
|
||||
target_meta=target_meta or {},
|
||||
reward_type=reward_type.value if isinstance(reward_type, TaskRewardType) else reward_type,
|
||||
reward_value=reward_value,
|
||||
reward_meta=reward_meta or {},
|
||||
allow_user_choice=allow_user_choice,
|
||||
user_audience=user_audience.value if isinstance(user_audience, TaskUserAudience) else user_audience,
|
||||
promo_group_id=promo_group_id,
|
||||
parent_task_id=parent_task_id,
|
||||
level=level,
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
logger.info('Создано задание', task_id=task.id, type=task.task_type, level=task.level)
|
||||
return task
|
||||
|
||||
|
||||
async def get_task_by_id(db: AsyncSession, task_id: int) -> Task | None:
|
||||
result = await db.execute(
|
||||
select(Task).options(selectinload(Task.promo_group)).where(Task.id == task_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_tasks(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
parent_task_id: int | None = None,
|
||||
) -> list[Task]:
|
||||
"""Список заданий (для админа). По умолчанию исключает неактивные."""
|
||||
stmt = (
|
||||
select(Task)
|
||||
.options(selectinload(Task.promo_group))
|
||||
.order_by(Task.sort_order, Task.id)
|
||||
)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(Task.is_active == True)
|
||||
if parent_task_id is not None:
|
||||
stmt = stmt.where(Task.parent_task_id == parent_task_id)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_task(db: AsyncSession, task: Task, **fields: Any) -> Task:
|
||||
"""Обновляет поля задания. Enum-поля принимаются как enum или строка."""
|
||||
for key, value in fields.items():
|
||||
if key == 'task_type' and isinstance(value, TaskType):
|
||||
value = value.value
|
||||
if key == 'reward_type' and isinstance(value, TaskRewardType):
|
||||
value = value.value
|
||||
if key == 'user_audience' and isinstance(value, TaskUserAudience):
|
||||
value = value.value
|
||||
setattr(task, key, value)
|
||||
task.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
logger.info('Обновлено задание', task_id=task.id)
|
||||
return task
|
||||
|
||||
|
||||
async def delete_task(db: AsyncSession, task: Task) -> None:
|
||||
await db.delete(task)
|
||||
await db.commit()
|
||||
logger.info('Удалено задание', task_id=task.id)
|
||||
|
||||
|
||||
async def list_active_tasks_for_user(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_audience: TaskUserAudience | str,
|
||||
promo_group_id: int | None,
|
||||
now: datetime | None = None,
|
||||
) -> list[Task]:
|
||||
"""Возвращает активные задания, доступные конкретному пользователю.
|
||||
|
||||
Учитывает:
|
||||
- is_active = True
|
||||
- starts_at <= now <= ends_at (если заданы)
|
||||
- user_audience: задание для 'both' или совпадающего типа аудитории
|
||||
- promo_group_id: задание без промогруппы либо совпадающей с user
|
||||
"""
|
||||
audience_value = (
|
||||
user_audience.value if isinstance(user_audience, TaskUserAudience) else user_audience
|
||||
)
|
||||
now = now or datetime.now(UTC)
|
||||
|
||||
stmt = (
|
||||
select(Task)
|
||||
.where(Task.is_active == True)
|
||||
.where((Task.starts_at == None) | (Task.starts_at <= now))
|
||||
.where((Task.ends_at == None) | (Task.ends_at >= now))
|
||||
.where(Task.user_audience.in_(['both', audience_value]))
|
||||
.order_by(Task.level, Task.sort_order, Task.id)
|
||||
)
|
||||
if promo_group_id is not None:
|
||||
# Задание без promo_group_id — для всех; либо ровно та же группа
|
||||
stmt = stmt.where((Task.promo_group_id == None) | (Task.promo_group_id == promo_group_id))
|
||||
else:
|
||||
stmt = stmt.where(Task.promo_group_id == None)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# UserTaskProgress CRUD
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
async def get_progress(db: AsyncSession, *, user_id: int, task_id: int) -> UserTaskProgress | None:
|
||||
result = await db.execute(
|
||||
select(UserTaskProgress)
|
||||
.options(selectinload(UserTaskProgress.task))
|
||||
.where(UserTaskProgress.user_id == user_id, UserTaskProgress.task_id == task_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_progress_for_update(
|
||||
db: AsyncSession, *, user_id: int, task_id: int
|
||||
) -> UserTaskProgress | None:
|
||||
"""FOR UPDATE lock — для атомарного claim."""
|
||||
result = await db.execute(
|
||||
select(UserTaskProgress)
|
||||
.where(UserTaskProgress.user_id == user_id, UserTaskProgress.task_id == task_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_progress_by_id_for_update(db: AsyncSession, progress_id: int) -> UserTaskProgress | None:
|
||||
result = await db.execute(
|
||||
select(UserTaskProgress)
|
||||
.where(UserTaskProgress.id == progress_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_or_create_progress(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
task_id: int,
|
||||
period_started_at: datetime | None = None,
|
||||
baseline_value: int = 0,
|
||||
) -> tuple[UserTaskProgress, bool]:
|
||||
"""Получает или создаёт запись прогресса. Возвращает (progress, created).
|
||||
|
||||
На PostgreSQL использует ``INSERT ... ON CONFLICT DO NOTHING`` (атомарный upsert),
|
||||
защищая от race на uq_user_task при параллельных record_event.
|
||||
|
||||
На SQLite (dev/test mode) использует savepoint + try/IntegrityError — atomic upsert
|
||||
тоже доступен в SQLite dialect, но проще и надёжнее savepoint pattern.
|
||||
"""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.database.database import IS_SQLITE
|
||||
|
||||
if IS_SQLITE:
|
||||
existing = await get_progress(db, user_id=user_id, task_id=task_id)
|
||||
if existing is not None:
|
||||
return existing, False
|
||||
|
||||
# ВАЖНО: ``db.add(progress)`` ДОЛЖЕН быть внутри ``begin_nested()``, иначе
|
||||
# SQLAlchemy в ``_take_snapshot`` сделает flush до открытия savepoint, и
|
||||
# IntegrityError повредит outer transaction вместо savepoint. См. эталонный
|
||||
# паттерн в ``app/database/crud/promocode.py``.
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
progress = UserTaskProgress(
|
||||
user_id=user_id,
|
||||
task_id=task_id,
|
||||
current_value=0,
|
||||
baseline_value=baseline_value,
|
||||
period_started_at=period_started_at,
|
||||
)
|
||||
db.add(progress)
|
||||
await db.flush()
|
||||
return progress, True
|
||||
except IntegrityError:
|
||||
# Параллельный insert проскочил впереди — fetch'нем существующую запись.
|
||||
# Savepoint уже откатился, _new очищен через _restore_snapshot.
|
||||
existing = await get_progress(db, user_id=user_id, task_id=task_id)
|
||||
if existing is None:
|
||||
raise RuntimeError('UserTaskProgress race: row disappeared after IntegrityError')
|
||||
return existing, False
|
||||
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
stmt = (
|
||||
pg_insert(UserTaskProgress)
|
||||
.values(
|
||||
user_id=user_id,
|
||||
task_id=task_id,
|
||||
current_value=0,
|
||||
baseline_value=baseline_value,
|
||||
period_started_at=period_started_at,
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=['user_id', 'task_id'])
|
||||
.returning(UserTaskProgress.id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
row = result.first()
|
||||
created = row is not None
|
||||
if created:
|
||||
await db.flush()
|
||||
|
||||
progress = await get_progress(db, user_id=user_id, task_id=task_id)
|
||||
if progress is None:
|
||||
# Не должно случаться: либо мы только что вставили, либо запись уже была.
|
||||
raise RuntimeError('UserTaskProgress disappeared after upsert')
|
||||
return progress, created
|
||||
|
||||
|
||||
async def list_user_progress(
|
||||
db: AsyncSession, *, user_id: int, task_ids: list[int] | None = None
|
||||
) -> list[UserTaskProgress]:
|
||||
stmt = (
|
||||
select(UserTaskProgress)
|
||||
.options(selectinload(UserTaskProgress.task))
|
||||
.where(UserTaskProgress.user_id == user_id)
|
||||
)
|
||||
if task_ids is not None:
|
||||
stmt = stmt.where(UserTaskProgress.task_id.in_(task_ids))
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def mark_progress_completed(
|
||||
db: AsyncSession, progress: UserTaskProgress
|
||||
) -> UserTaskProgress:
|
||||
if progress.completed_at is None:
|
||||
progress.completed_at = datetime.now(UTC)
|
||||
progress.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return progress
|
||||
|
||||
|
||||
async def mark_progress_claimed(
|
||||
db: AsyncSession,
|
||||
progress: UserTaskProgress,
|
||||
*,
|
||||
reward_granted_meta: dict[str, Any] | None = None,
|
||||
) -> UserTaskProgress:
|
||||
if progress.claimed_at is None:
|
||||
progress.claimed_at = datetime.now(UTC)
|
||||
if reward_granted_meta is not None:
|
||||
progress.reward_granted_meta = reward_granted_meta
|
||||
progress.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return progress
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TaskPartnerChannel CRUD
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
async def list_partner_channels(db: AsyncSession, *, include_inactive: bool = False) -> list[TaskPartnerChannel]:
|
||||
stmt = select(TaskPartnerChannel).order_by(TaskPartnerChannel.sort_order, TaskPartnerChannel.id)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(TaskPartnerChannel.is_active == True)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_partner_channel_by_id(db: AsyncSession, channel_pk: int) -> TaskPartnerChannel | None:
|
||||
result = await db.execute(
|
||||
select(TaskPartnerChannel).where(TaskPartnerChannel.id == channel_pk)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_partner_channel_by_channel_id(
|
||||
db: AsyncSession, channel_id: str
|
||||
) -> TaskPartnerChannel | None:
|
||||
result = await db.execute(
|
||||
select(TaskPartnerChannel).where(TaskPartnerChannel.channel_id == channel_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_partner_channel(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
channel_id: str,
|
||||
title: str,
|
||||
channel_link: str | None = None,
|
||||
description: str | None = None,
|
||||
is_active: bool = True,
|
||||
sort_order: int = 0,
|
||||
) -> TaskPartnerChannel:
|
||||
channel = TaskPartnerChannel(
|
||||
channel_id=channel_id,
|
||||
title=title,
|
||||
channel_link=channel_link,
|
||||
description=description,
|
||||
is_active=is_active,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
db.add(channel)
|
||||
await db.commit()
|
||||
await db.refresh(channel)
|
||||
logger.info('Создан партнёрский канал', channel_id=channel_id)
|
||||
return channel
|
||||
|
||||
|
||||
async def update_partner_channel(
|
||||
db: AsyncSession, channel: TaskPartnerChannel, **fields: Any
|
||||
) -> TaskPartnerChannel:
|
||||
for key, value in fields.items():
|
||||
setattr(channel, key, value)
|
||||
channel.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(channel)
|
||||
return channel
|
||||
|
||||
|
||||
async def delete_partner_channel(db: AsyncSession, channel: TaskPartnerChannel) -> None:
|
||||
await db.delete(channel)
|
||||
await db.commit()
|
||||
@@ -25,6 +25,7 @@ class TicketCRUD:
|
||||
media_type: str | None = None,
|
||||
media_file_id: str | None = None,
|
||||
media_caption: str | None = None,
|
||||
media_items: list[dict] | None = None,
|
||||
) -> Ticket:
|
||||
"""Создать новый тикет с первым сообщением"""
|
||||
ticket = Ticket(user_id=user_id, title=title, status=TicketStatus.OPEN.value, priority=priority)
|
||||
@@ -37,10 +38,11 @@ class TicketCRUD:
|
||||
user_id=user_id,
|
||||
message_text=message_text,
|
||||
is_from_admin=False,
|
||||
has_media=bool(media_type and media_file_id),
|
||||
has_media=bool(media_type and media_file_id) or bool(media_items),
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
media_items=media_items,
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
@@ -381,6 +383,7 @@ class TicketMessageCRUD:
|
||||
media_type: str | None = None,
|
||||
media_file_id: str | None = None,
|
||||
media_caption: str | None = None,
|
||||
media_items: list[dict] | None = None,
|
||||
) -> TicketMessage:
|
||||
"""Добавить сообщение в тикет"""
|
||||
message = TicketMessage(
|
||||
@@ -388,10 +391,11 @@ class TicketMessageCRUD:
|
||||
user_id=user_id,
|
||||
message_text=message_text,
|
||||
is_from_admin=is_from_admin,
|
||||
has_media=bool(media_type and media_file_id),
|
||||
has_media=bool(media_type and media_file_id) or bool(media_items),
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
media_items=media_items,
|
||||
)
|
||||
|
||||
db.add(message)
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
@@ -126,6 +132,28 @@ async def create_transaction(
|
||||
except Exception as exc:
|
||||
logger.debug('Не удалось записать событие конкурса для пользователя', user_id=user_id, exc=exc)
|
||||
|
||||
# Tasks: SPEND_AMOUNT — учитываем потраченное на подписку. Триггер также есть
|
||||
# в emit_transaction_side_effects (для commit=False callers); тут — для commit=True.
|
||||
try:
|
||||
from app.database.models import TaskType
|
||||
from app.services.tasks_service import record_event
|
||||
|
||||
await record_event(
|
||||
db,
|
||||
user_id=user_id,
|
||||
event_type=TaskType.SPEND_AMOUNT,
|
||||
payload={'amount_kopeks': abs(amount_kopeks), 'is_trial': False},
|
||||
)
|
||||
# commit=True путь: фиксируем task-прогресс в той же транзакции
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
# Откатываем poisoned state, чтобы caller не получил PendingRollbackError.
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning('Tasks: SPEND_AMOUNT прогресс не обновлён', user_id=user_id, exc=exc)
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
@@ -187,6 +215,20 @@ async def emit_transaction_side_effects(
|
||||
except Exception as exc:
|
||||
logger.debug('Не удалось записать событие конкурса для пользователя', user_id=user_id, exc=exc)
|
||||
|
||||
# Tasks: SPEND_AMOUNT — учитываем потраченное на подписку
|
||||
try:
|
||||
from app.database.models import TaskType
|
||||
from app.services.tasks_service import record_event
|
||||
|
||||
await record_event(
|
||||
db,
|
||||
user_id=user_id,
|
||||
event_type=TaskType.SPEND_AMOUNT,
|
||||
payload={'amount_kopeks': abs(amount_kopeks), 'is_trial': False},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug('Tasks: не удалось обновить SPEND_AMOUNT прогресс', user_id=user_id, exc=exc)
|
||||
|
||||
|
||||
async def get_transaction_by_id(db: AsyncSession, transaction_id: int) -> Transaction | None:
|
||||
result = await db.execute(
|
||||
@@ -230,6 +272,11 @@ async def get_user_transactions_count(
|
||||
|
||||
|
||||
async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
|
||||
"""Sum of personal spending for promo group auto-assignment.
|
||||
|
||||
Only counts SUBSCRIPTION_PAYMENT (user's own subscriptions).
|
||||
GIFT_PAYMENT is excluded — buying a gift for someone else is not personal spending.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
|
||||
+148
-26
@@ -3,7 +3,7 @@ import string
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, case, func, nullslast, or_, select, text
|
||||
from sqlalchemy import and_, case, exists, func, nullslast, or_, select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -13,6 +13,8 @@ from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
|
||||
from app.database.crud.promo_group import get_default_promo_group
|
||||
from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
PaymentMethod,
|
||||
PromoGroup,
|
||||
Subscription,
|
||||
@@ -509,6 +511,9 @@ async def add_user_balance(
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import create_transaction as create_trans
|
||||
|
||||
# Пропагируем commit=False вниз, иначе вложенный create_trans выпустит
|
||||
# преждевременный db.commit() и сбросит FOR UPDATE-локи (включая progress lock
|
||||
# из claim_reward).
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
@@ -516,6 +521,7 @@ async def add_user_balance(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=payment_method,
|
||||
commit=commit,
|
||||
)
|
||||
|
||||
if commit:
|
||||
@@ -710,30 +716,52 @@ async def subtract_user_balance(
|
||||
await db.refresh(user)
|
||||
|
||||
if consume_promo_offer and log_context:
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
offer_id=log_context.get('offer_id'),
|
||||
action='consumed',
|
||||
source=log_context.get('source'),
|
||||
percent=log_context.get('percent'),
|
||||
effect_type=log_context.get('effect_type'),
|
||||
details=log_context.get('details'),
|
||||
commit=commit,
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to record promo offer consumption log for user', user_id=user.id, log_error=log_error
|
||||
)
|
||||
if commit:
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to rollback session after promo offer consumption log failure',
|
||||
rollback_error=rollback_error,
|
||||
# Пишем лог в ОТДЕЛЬНОЙ сессии, чтобы его commit/rollback не касался
|
||||
# основной сессии caller'а. Иначе rollback в случае фейла логирования
|
||||
# экспайрит объекты сессии и следующее обращение к subscription/user
|
||||
# attrs у caller'а падает с MissingGreenlet.
|
||||
if commit:
|
||||
try:
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as log_db:
|
||||
await log_promo_offer_action(
|
||||
log_db,
|
||||
user_id=user.id,
|
||||
offer_id=log_context.get('offer_id'),
|
||||
action='consumed',
|
||||
source=log_context.get('source'),
|
||||
percent=log_context.get('percent'),
|
||||
effect_type=log_context.get('effect_type'),
|
||||
details=log_context.get('details'),
|
||||
commit=True,
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to record promo offer consumption log for user',
|
||||
user_id=user.id,
|
||||
log_error=log_error,
|
||||
)
|
||||
else:
|
||||
# Caller управляет транзакцией — пишем в его сессию без commit.
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
offer_id=log_context.get('offer_id'),
|
||||
action='consumed',
|
||||
source=log_context.get('source'),
|
||||
percent=log_context.get('percent'),
|
||||
effect_type=log_context.get('effect_type'),
|
||||
details=log_context.get('details'),
|
||||
commit=False,
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to record promo offer consumption log for user',
|
||||
user_id=user.id,
|
||||
log_error=log_error,
|
||||
)
|
||||
|
||||
logger.info('✅ Средства списаны: →', old_balance=old_balance, balance_kopeks=user.balance_kopeks)
|
||||
return True
|
||||
@@ -839,6 +867,11 @@ async def get_users_list(
|
||||
search: str | None = None,
|
||||
email: str | None = None,
|
||||
status: UserStatus | None = None,
|
||||
subscription_status: str | None = None,
|
||||
tariff_ids: list[int] | None = None,
|
||||
promo_group_id: int | None = None,
|
||||
campaign_id: int | None = None,
|
||||
partner_id: int | None = None,
|
||||
order_by_balance: bool = False,
|
||||
order_by_traffic: bool = False,
|
||||
order_by_last_activity: bool = False,
|
||||
@@ -854,6 +887,41 @@ async def get_users_list(
|
||||
if status:
|
||||
query = query.where(User.status == status.value)
|
||||
|
||||
# Subscription-level filters via subquery
|
||||
if subscription_status or tariff_ids:
|
||||
sub_conditions = []
|
||||
if subscription_status:
|
||||
sub_conditions.append(Subscription.status == subscription_status)
|
||||
if tariff_ids:
|
||||
sub_conditions.append(Subscription.tariff_id.in_(tariff_ids))
|
||||
sub_query = select(Subscription.user_id).where(and_(*sub_conditions)).distinct().scalar_subquery()
|
||||
query = query.where(User.id.in_(sub_query))
|
||||
|
||||
if promo_group_id:
|
||||
query = query.where(User.promo_group_id == promo_group_id)
|
||||
|
||||
if campaign_id:
|
||||
query = query.where(
|
||||
exists(
|
||||
select(AdvertisingCampaignRegistration.id).where(
|
||||
AdvertisingCampaignRegistration.user_id == User.id,
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if partner_id:
|
||||
query = query.where(
|
||||
exists(
|
||||
select(AdvertisingCampaignRegistration.id)
|
||||
.join(AdvertisingCampaign, AdvertisingCampaign.id == AdvertisingCampaignRegistration.campaign_id)
|
||||
.where(
|
||||
AdvertisingCampaignRegistration.user_id == User.id,
|
||||
AdvertisingCampaign.partner_user_id == partner_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if search:
|
||||
search_term = f'%{search}%'
|
||||
conditions = [
|
||||
@@ -933,13 +1001,55 @@ async def get_users_list(
|
||||
|
||||
|
||||
async def get_users_count(
|
||||
db: AsyncSession, status: UserStatus | None = None, search: str | None = None, email: str | None = None
|
||||
db: AsyncSession,
|
||||
status: UserStatus | None = None,
|
||||
search: str | None = None,
|
||||
email: str | None = None,
|
||||
subscription_status: str | None = None,
|
||||
tariff_ids: list[int] | None = None,
|
||||
promo_group_id: int | None = None,
|
||||
campaign_id: int | None = None,
|
||||
partner_id: int | None = None,
|
||||
) -> int:
|
||||
query = select(func.count(User.id))
|
||||
|
||||
if status:
|
||||
query = query.where(User.status == status.value)
|
||||
|
||||
if subscription_status or tariff_ids:
|
||||
sub_conditions = []
|
||||
if subscription_status:
|
||||
sub_conditions.append(Subscription.status == subscription_status)
|
||||
if tariff_ids:
|
||||
sub_conditions.append(Subscription.tariff_id.in_(tariff_ids))
|
||||
sub_query = select(Subscription.user_id).where(and_(*sub_conditions)).distinct().scalar_subquery()
|
||||
query = query.where(User.id.in_(sub_query))
|
||||
|
||||
if promo_group_id:
|
||||
query = query.where(User.promo_group_id == promo_group_id)
|
||||
|
||||
if campaign_id:
|
||||
query = query.where(
|
||||
exists(
|
||||
select(AdvertisingCampaignRegistration.id).where(
|
||||
AdvertisingCampaignRegistration.user_id == User.id,
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if partner_id:
|
||||
query = query.where(
|
||||
exists(
|
||||
select(AdvertisingCampaignRegistration.id)
|
||||
.join(AdvertisingCampaign, AdvertisingCampaign.id == AdvertisingCampaignRegistration.campaign_id)
|
||||
.where(
|
||||
AdvertisingCampaignRegistration.user_id == User.id,
|
||||
AdvertisingCampaign.partner_user_id == partner_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if search:
|
||||
search_term = f'%{search}%'
|
||||
conditions = [
|
||||
@@ -1090,6 +1200,12 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> list[Us
|
||||
async def get_inactive_users(db: AsyncSession, months: int = 3) -> list[User]:
|
||||
threshold_date = datetime.now(UTC) - timedelta(days=months * 30)
|
||||
|
||||
# Подзапрос: пользователи, у которых есть подписка с end_date >= threshold
|
||||
# (активная или недавно истёкшая) — таких удалять нельзя
|
||||
users_with_recent_subs = (
|
||||
select(Subscription.user_id).where(Subscription.end_date >= threshold_date).distinct().scalar_subquery()
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
@@ -1098,7 +1214,13 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> list[User]:
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(and_(User.last_activity < threshold_date, User.status == UserStatus.ACTIVE.value))
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity < threshold_date,
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
User.id.not_in(users_with_recent_subs),
|
||||
)
|
||||
)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""CRUD operations for yandex_client_id_map table."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import YandexClientIdMap
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def upsert_cid(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
cid: str,
|
||||
source: str = 'web',
|
||||
counter_id: str | None = None,
|
||||
subid: str | None = None,
|
||||
) -> YandexClientIdMap:
|
||||
"""Insert or update Yandex ClientID for a user (race-safe via ON CONFLICT)."""
|
||||
now = datetime.now(UTC)
|
||||
values = {
|
||||
'yandex_cid': cid,
|
||||
'source': source,
|
||||
'updated_at': now,
|
||||
}
|
||||
if counter_id:
|
||||
values['counter_id'] = counter_id
|
||||
if subid:
|
||||
values['subid'] = subid
|
||||
|
||||
stmt = (
|
||||
pg_insert(YandexClientIdMap)
|
||||
.values(user_id=user_id, yandex_cid=cid, source=source, counter_id=counter_id, subid=subid)
|
||||
.on_conflict_do_update(index_elements=['user_id'], set_=values)
|
||||
.returning(YandexClientIdMap)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
await db.flush()
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def get_cid(db: AsyncSession, user_id: int) -> YandexClientIdMap | None:
|
||||
"""Get Yandex ClientID mapping for a user."""
|
||||
result = await db.execute(select(YandexClientIdMap).where(YandexClientIdMap.user_id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def mark_registration_sent(db: AsyncSession, user_id: int) -> None:
|
||||
"""Mark registration event as sent for a user."""
|
||||
await db.execute(
|
||||
update(YandexClientIdMap)
|
||||
.where(YandexClientIdMap.user_id == user_id)
|
||||
.values(registration_sent=True, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def mark_trial_sent(db: AsyncSession, user_id: int) -> None:
|
||||
"""Mark trial event as sent for a user."""
|
||||
await db.execute(
|
||||
update(YandexClientIdMap)
|
||||
.where(YandexClientIdMap.user_id == user_id)
|
||||
.values(trial_sent=True, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def upsert_subid(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
subid: str,
|
||||
source: str = 'web',
|
||||
) -> None:
|
||||
"""Save subid for a user. Updates existing record or creates with placeholder CID."""
|
||||
if not subid or len(subid) > 255:
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
# Try update first (don't create empty CID records)
|
||||
result = await db.execute(
|
||||
update(YandexClientIdMap).where(YandexClientIdMap.user_id == user_id).values(subid=subid, updated_at=now)
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
# No existing record — create with placeholder
|
||||
stmt = (
|
||||
pg_insert(YandexClientIdMap)
|
||||
.values(user_id=user_id, yandex_cid='_subid_only', source=source, subid=subid)
|
||||
.on_conflict_do_update(
|
||||
index_elements=['user_id'],
|
||||
set_={'subid': subid, 'updated_at': now},
|
||||
)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.flush()
|
||||
logger.info('Subid saved', user_id=user_id, subid=subid, source=source)
|
||||
|
||||
|
||||
async def get_subid(db: AsyncSession, user_id: int) -> str | None:
|
||||
"""Get subid for a user."""
|
||||
result = await db.execute(select(YandexClientIdMap.subid).where(YandexClientIdMap.user_id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
+839
-2
@@ -162,6 +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'
|
||||
|
||||
@@ -325,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'
|
||||
|
||||
@@ -878,6 +923,566 @@ class SeverPayPayment(Base):
|
||||
return f'<SeverPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class PayPearPayment(Base):
|
||||
"""Платежи через PayPear (paypear.ru)."""
|
||||
|
||||
__tablename__ = 'paypear_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
paypear_id = Column(String(64), unique=True, nullable=True, index=True) # ID от PayPear
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_method = Column(String(32), nullable=True)
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='paypear_payments')
|
||||
transaction = relationship('Transaction', backref='paypear_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'canceled', 'amount_mismatch']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<PayPearPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class RollyPayPayment(Base):
|
||||
"""Платежи через RollyPay (rollypay.io)."""
|
||||
|
||||
__tablename__ = 'rollypay_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
rollypay_payment_id = Column(String(128), unique=True, nullable=True, index=True) # pay_uuid от RollyPay
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_method = Column(String(32), nullable=True)
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='rollypay_payments')
|
||||
transaction = relationship('Transaction', backref='rollypay_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'canceled', 'chargeback', 'amount_mismatch']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<RollyPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class OverpayPayment(Base):
|
||||
"""Платежи через Overpay (pay.overpay.io)."""
|
||||
|
||||
__tablename__ = 'overpay_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
|
||||
overpay_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Overpay
|
||||
|
||||
# Суммы
|
||||
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='overpay_payments')
|
||||
transaction = relationship('Transaction', backref='overpay_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'canceled', 'chargeback', 'amount_mismatch']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<OverpayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class AuraPayPayment(Base):
|
||||
"""Платежи через AuraPay (aurapay.tech)."""
|
||||
|
||||
__tablename__ = 'aurapay_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
aurapay_invoice_id = Column(String(128), unique=True, nullable=True, index=True) # UUID от AuraPay
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_method = Column(String(32), nullable=True)
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='aurapay_payments')
|
||||
transaction = relationship('Transaction', backref='aurapay_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'canceled', 'amount_mismatch']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<AuraPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class 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'
|
||||
|
||||
@@ -1044,6 +1649,10 @@ class Tariff(Base):
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift = Column(Boolean, default=True, server_default='true', nullable=False)
|
||||
|
||||
# Бонусные дни — сколько дней начислять при выдаче награды subscription_days,
|
||||
# если задание ссылается на этот тариф (используется системой Tasks)
|
||||
bonus_days_per_purchase = Column(Integer, default=0, nullable=False, server_default='0')
|
||||
|
||||
# Режим сброса трафика: DAY, WEEK, MONTH, MONTH_ROLLING, NO_RESET (по умолчанию берётся из конфига)
|
||||
traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку
|
||||
|
||||
@@ -1204,6 +1813,8 @@ class User(Base):
|
||||
password_reset_token = Column(String(255), nullable=True)
|
||||
password_reset_expires = Column(AwareDateTime(), nullable=True)
|
||||
cabinet_last_login = Column(AwareDateTime(), nullable=True)
|
||||
# Campaign slug saved at registration, consumed at email verification
|
||||
pending_campaign_slug = Column(String(64), nullable=True)
|
||||
# Email change fields
|
||||
email_change_new = Column(String(255), nullable=True) # New email pending verification
|
||||
email_change_code = Column(String(6), nullable=True) # 6-digit verification code
|
||||
@@ -1255,7 +1866,7 @@ class User(Base):
|
||||
user_promo_groups = relationship('UserPromoGroup', back_populates='user', cascade='all, delete-orphan')
|
||||
poll_responses = relationship('PollResponse', back_populates='user')
|
||||
admin_roles_rel = relationship('UserRole', foreign_keys='[UserRole.user_id]', back_populates='user')
|
||||
notification_settings = Column(JSON, nullable=True, default=dict)
|
||||
notification_settings = Column(JSONB, nullable=True, default=dict)
|
||||
last_pinned_message_id = Column(Integer, nullable=True)
|
||||
|
||||
# Ограничения пользователя
|
||||
@@ -1357,7 +1968,7 @@ class Subscription(Base):
|
||||
'user_id',
|
||||
'tariff_id',
|
||||
unique=True,
|
||||
postgresql_where=text("tariff_id IS NOT NULL AND status IN ('active', 'trial')"),
|
||||
postgresql_where=text("tariff_id IS NOT NULL AND status IN ('active', 'trial', 'limited')"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1392,6 +2003,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)
|
||||
@@ -2238,6 +2850,9 @@ class BroadcastHistory(Base):
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
completed_at = Column(AwareDateTime(), nullable=True)
|
||||
|
||||
# Broadcast category for user notification preferences filtering
|
||||
category = Column(String(20), default='system', nullable=False) # system|news|promo
|
||||
|
||||
# Email broadcast fields
|
||||
channel = Column(String(20), default='telegram', nullable=False) # telegram|email|both
|
||||
email_subject = Column(String(255), nullable=True)
|
||||
@@ -2642,6 +3257,8 @@ class TicketMessage(Base):
|
||||
media_type = Column(String(20), nullable=True) # photo, video, document, voice, etc.
|
||||
media_file_id = Column(String(255), nullable=True)
|
||||
media_caption = Column(Text, nullable=True)
|
||||
# Multi-media gallery (photos/videos/documents bundled in one bubble)
|
||||
media_items = Column(JSONB, nullable=True)
|
||||
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
|
||||
@@ -3263,6 +3880,13 @@ class LandingPage(Base):
|
||||
background_config = Column(
|
||||
JSON, nullable=True
|
||||
) # AnimationConfig: {enabled, type, settings, opacity, blur, reducedOnMobile}
|
||||
# Sticky pay button on mobile (full-width fixed bottom)
|
||||
sticky_pay_button = Column(Boolean, nullable=False, default=False, server_default=text('false'))
|
||||
# Yandex Metrika landing-level conversion goals
|
||||
analytics_view_enabled = Column(Boolean, nullable=False, default=False, server_default=text('false'))
|
||||
analytics_view_goal = Column(String(64), nullable=True)
|
||||
analytics_click_enabled = Column(Boolean, nullable=False, default=False, server_default=text('false'))
|
||||
analytics_click_goal = Column(String(64), nullable=True)
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -3325,6 +3949,10 @@ class GuestPurchase(Base):
|
||||
retry_count = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
receipt_uuid = Column(String(255), nullable=True, index=True)
|
||||
receipt_created_at = Column(AwareDateTime(), nullable=True)
|
||||
# Yandex Metrika offline conversions: client identifier + traffic source tags
|
||||
yandex_cid = Column(String(128), nullable=True)
|
||||
subid = Column(String(255), nullable=True)
|
||||
referrer = Column(String(500), nullable=True)
|
||||
|
||||
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
|
||||
tariff = relationship('Tariff', lazy='selectin')
|
||||
@@ -3406,3 +4034,212 @@ class NewsTag(Base):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<NewsTag id={self.id} name='{self.name}'>"
|
||||
|
||||
|
||||
class YandexClientIdMap(Base):
|
||||
"""Yandex Metrika client identifier captured per user.
|
||||
|
||||
Stores the mapping user_id -> yandex_cid so we can fire offline
|
||||
conversion events to mc.yandex.ru with the right CID even after
|
||||
the user leaves the landing/web flow. The ``subid`` column carries
|
||||
a pass-through traffic-source identifier for S2S postbacks.
|
||||
"""
|
||||
|
||||
__tablename__ = 'yandex_client_id_map'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), unique=True, nullable=False)
|
||||
yandex_cid = Column(String(128), nullable=False)
|
||||
source = Column(String(20), nullable=False, default='web', server_default='web')
|
||||
counter_id = Column(String(32), nullable=True)
|
||||
registration_sent = Column(Boolean, default=False, server_default=text('false'), nullable=False)
|
||||
trial_sent = Column(Boolean, default=False, server_default=text('false'), nullable=False)
|
||||
subid = Column(String(255), nullable=True)
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class InfoPage(Base):
|
||||
"""Static informational page with multilingual title/content (JSONB)."""
|
||||
|
||||
__tablename__ = 'info_pages'
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
slug = Column(String(200), unique=True, nullable=False)
|
||||
title = Column(JSONB, nullable=False, server_default='{}')
|
||||
content = Column(JSONB, nullable=False, server_default='{}')
|
||||
page_type = Column(String(20), nullable=False, default='page', server_default='page')
|
||||
is_active = Column(Boolean, nullable=False, default=True, server_default='true')
|
||||
sort_order = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
icon = Column(String(50), nullable=True)
|
||||
replaces_tab = Column(String(20), nullable=True) # 'faq', 'rules', 'privacy', 'offer', or null
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class TaskType(Enum):
|
||||
"""Тип задания (что нужно выполнить пользователю)."""
|
||||
|
||||
PURCHASE_TARIFF = 'purchase_tariff' # купить конкретный тариф
|
||||
SUBSCRIBE_CHANNEL = 'subscribe_channel' # подписаться на партнёрский канал
|
||||
TRAFFIC_USED = 'traffic_used' # использовать N ГБ трафика за месяц на подписке
|
||||
REFERRALS_INVITED = 'referrals_invited' # пригласить N рефералов
|
||||
PURCHASE_PERIOD = 'purchase_period' # купить любой тариф минимум на N дней
|
||||
SPEND_AMOUNT = 'spend_amount' # совокупно потратить N копеек
|
||||
MULTI_TARIFF = 'multi_tariff' # иметь N+ активных тарифов в multi-tariff режиме
|
||||
GIFT_PURCHASED = 'gift_purchased' # купить хотя бы 1 подписку в подарок
|
||||
GIFTS_COUNT = 'gifts_count' # купить N подписок в подарок (накопительно)
|
||||
|
||||
|
||||
class TaskRewardType(Enum):
|
||||
"""Тип награды за задание."""
|
||||
|
||||
BALANCE = 'balance' # деньги на баланс (в копейках)
|
||||
SUBSCRIPTION_DAYS = 'subscription_days' # бонусные дни подписки
|
||||
|
||||
|
||||
class TaskUserAudience(Enum):
|
||||
"""Аудитория задания (фильтр по типу пользователя)."""
|
||||
|
||||
TELEGRAM = 'telegram' # только Telegram-юзеры
|
||||
EMAIL = 'email' # только email-юзеры (cabinet)
|
||||
BOTH = 'both' # все
|
||||
|
||||
|
||||
class TaskPartnerChannel(Base):
|
||||
"""Партнёрский канал, на который можно требовать подписку в задании.
|
||||
|
||||
Отдельный список от ``RequiredChannel`` (обязательная подписка), чтобы каналы
|
||||
для заданий не пересекались с системой обязательной подписки.
|
||||
"""
|
||||
|
||||
__tablename__ = 'task_partner_channels'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
channel_id = Column(String(100), unique=True, nullable=False, index=True) # формат -100xxxxxxxx
|
||||
title = Column(String(255), nullable=False)
|
||||
channel_link = Column(String(500), nullable=True) # https://t.me/...
|
||||
description = Column(Text, nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True, server_default='true')
|
||||
sort_order = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class Task(Base):
|
||||
"""Шаблон задания с наградой.
|
||||
|
||||
Создаётся админом, выдаётся пользователям. Прогресс трекается в ``UserTaskProgress``.
|
||||
"""
|
||||
|
||||
__tablename__ = 'tasks'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Идентификация и видимость
|
||||
title = Column(JSONB, nullable=False, server_default='{}') # i18n
|
||||
description = Column(JSONB, nullable=False, server_default='{}') # i18n
|
||||
icon = Column(String(50), nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True, server_default='true')
|
||||
sort_order = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
|
||||
# Тип задания и его параметры
|
||||
task_type = Column(String(32), nullable=False, index=True) # значение TaskType.value
|
||||
target_value = Column(BigInteger, nullable=False, default=1) # цель: 5 рефералов / 100 ГБ / N копеек
|
||||
# Дополнительные параметры в зависимости от типа:
|
||||
# - PURCHASE_TARIFF: {"tariff_id": 12}
|
||||
# - SUBSCRIBE_CHANNEL: {"channel_id": "-1001234"}
|
||||
# - PURCHASE_PERIOD: {"period_days": 30}
|
||||
# - TRAFFIC_USED: {} (target_value = ГБ)
|
||||
# - SPEND_AMOUNT: {} (target_value = копейки)
|
||||
# - REFERRALS_INVITED / MULTI_TARIFF / GIFT_PURCHASED / GIFTS_COUNT: {} (target_value = шт.)
|
||||
target_meta = Column(JSON, nullable=False, default=dict, server_default='{}')
|
||||
|
||||
# Награда
|
||||
reward_type = Column(String(32), nullable=False) # значение TaskRewardType.value
|
||||
reward_value = Column(BigInteger, nullable=False, default=0) # копейки или дни
|
||||
# Для SUBSCRIPTION_DAYS: { "tariff_id": 12 } — если задано, дни начисляются на этот тариф
|
||||
# (если у юзера в multi-tariff несколько подписок — он выберет какую продлевать).
|
||||
reward_meta = Column(JSON, nullable=False, default=dict, server_default='{}')
|
||||
# Может ли user сам выбрать тип награды (если админ задал альтернативу в reward_meta.alt)
|
||||
allow_user_choice = Column(Boolean, nullable=False, default=False, server_default='false')
|
||||
|
||||
# Фильтры аудитории (значение TaskUserAudience.value: 'telegram' / 'email' / 'both')
|
||||
user_audience = Column(String(16), nullable=False, default='both', server_default='both')
|
||||
promo_group_id = Column(Integer, ForeignKey('promo_groups.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Цепочка уровней (последовательное открытие)
|
||||
parent_task_id = Column(Integer, ForeignKey('tasks.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
level = Column(Integer, nullable=False, default=1, server_default='1')
|
||||
|
||||
# Период действия задания (опционально)
|
||||
starts_at = Column(AwareDateTime(), nullable=True)
|
||||
ends_at = Column(AwareDateTime(), nullable=True)
|
||||
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
promo_group = relationship('PromoGroup', backref='tasks')
|
||||
parent_task = relationship('Task', remote_side=[id], backref='child_tasks')
|
||||
progress_records = relationship('UserTaskProgress', back_populates='task', cascade='all, delete-orphan')
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f'<Task(id={self.id}, type={self.task_type}, level={self.level})>'
|
||||
|
||||
|
||||
class UserTaskProgress(Base):
|
||||
"""Прогресс пользователя по конкретному заданию."""
|
||||
|
||||
__tablename__ = 'user_task_progress'
|
||||
__table_args__ = (UniqueConstraint('user_id', 'task_id', name='uq_user_task'),)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
task_id = Column(Integer, ForeignKey('tasks.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
|
||||
# Текущий прогресс к target_value (BigInteger чтобы поддерживать SPEND_AMOUNT в копейках за всё время)
|
||||
current_value = Column(BigInteger, nullable=False, default=0, server_default='0')
|
||||
|
||||
# Снапшоты для типов с периодом (TRAFFIC_USED — за месяц на подписке)
|
||||
# period_started_at — начало текущего окна (для traffic_used = первое число месяца)
|
||||
# baseline_value — снапшот значения на начало периода (для traffic_used = traffic_used_gb на старте)
|
||||
period_started_at = Column(AwareDateTime(), nullable=True)
|
||||
baseline_value = Column(BigInteger, nullable=False, default=0, server_default='0')
|
||||
|
||||
# Статусы
|
||||
completed_at = Column(AwareDateTime(), nullable=True) # когда выполнено
|
||||
claimed_at = Column(AwareDateTime(), nullable=True) # когда награда получена
|
||||
|
||||
# Метаданные о выданной награде:
|
||||
# { "type": "balance" | "subscription_days",
|
||||
# "value": 100000,
|
||||
# "subscription_id": 42, # для multi-tariff: какой подписке начислили дни
|
||||
# "transaction_id": 1234, # если создана транзакция
|
||||
# "old_end_date": "...",
|
||||
# "new_end_date": "..." }
|
||||
reward_granted_meta = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='task_progress')
|
||||
task = relationship('Task', back_populates='progress_records')
|
||||
|
||||
@property
|
||||
def is_completed(self) -> bool:
|
||||
return self.completed_at is not None
|
||||
|
||||
@property
|
||||
def is_claimed(self) -> bool:
|
||||
return self.claimed_at is not None
|
||||
|
||||
@property
|
||||
def percent(self) -> int:
|
||||
if not self.task or self.task.target_value <= 0:
|
||||
return 0
|
||||
ratio = max(0, min(self.current_value, self.task.target_value)) / self.task.target_value
|
||||
return int(ratio * 100)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f'<UserTaskProgress(user={self.user_id}, task={self.task_id}, {self.current_value}/{self.task.target_value if self.task else "?"})>'
|
||||
|
||||
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
+30
-8
@@ -376,15 +376,16 @@ class RemnaWaveAPI:
|
||||
except json.JSONDecodeError:
|
||||
response_data = {'raw_response': response_text}
|
||||
|
||||
if response.status == 429 and attempt < max_retries:
|
||||
if response.status in (429, 502, 503, 504) and attempt < max_retries:
|
||||
retry_after = float(response.headers.get('Retry-After', base_delay * (2**attempt)))
|
||||
logger.warning(
|
||||
'Rate limited (429) on , retry / after s',
|
||||
method=method,
|
||||
endpoint=endpoint,
|
||||
attempt=attempt + 1,
|
||||
max_retries=max_retries,
|
||||
retry_after=retry_after,
|
||||
'Retryable %s on %s %s, retry %s/%s after %ss',
|
||||
response.status,
|
||||
method,
|
||||
endpoint,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
retry_after,
|
||||
)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
@@ -445,7 +446,7 @@ class RemnaWaveAPI:
|
||||
'trafficLimitStrategy': traffic_limit_strategy.value,
|
||||
}
|
||||
|
||||
if telegram_id:
|
||||
if telegram_id is not None:
|
||||
data['telegramId'] = telegram_id
|
||||
if email:
|
||||
data['email'] = email
|
||||
@@ -540,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')]]
|
||||
),
|
||||
|
||||
@@ -63,7 +63,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
},
|
||||
'payments': {
|
||||
'title': '💳 Платежные системы',
|
||||
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay, SeverPay и Telegram Stars.',
|
||||
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay, SeverPay, PayPear, RollyPay и Telegram Stars.',
|
||||
'icon': '💳',
|
||||
'categories': (
|
||||
'PAYMENT',
|
||||
@@ -76,6 +76,10 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
'KASSA_AI',
|
||||
'RIOPAY',
|
||||
'SEVERPAY',
|
||||
'PAYPEAR',
|
||||
'ROLLYPAY',
|
||||
'OVERPAY',
|
||||
'AURAPAY',
|
||||
'MULENPAY',
|
||||
'PAL24',
|
||||
'WATA',
|
||||
@@ -154,7 +158,6 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
'LOG',
|
||||
'MODERATION',
|
||||
'DEBUG',
|
||||
'EXTERNAL_ADMIN',
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -1260,6 +1263,18 @@ def _build_settings_keyboard(
|
||||
elif category_key == 'SEVERPAY':
|
||||
label = texts.t('PAYMENT_SEVERPAY', f'💳 {settings.get_severpay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'severpay')])
|
||||
elif category_key == 'PAYPEAR':
|
||||
label = texts.t('PAYMENT_PAYPEAR', f'💳 {settings.get_paypear_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'paypear')])
|
||||
elif category_key == 'ROLLYPAY':
|
||||
label = texts.t('PAYMENT_ROLLYPAY', f'💳 {settings.get_rollypay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'rollypay')])
|
||||
elif category_key == 'OVERPAY':
|
||||
label = texts.t('PAYMENT_OVERPAY', f'💳 {settings.get_overpay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'overpay')])
|
||||
elif category_key == 'AURAPAY':
|
||||
label = texts.t('PAYMENT_AURAPAY', f'💳 {settings.get_aurapay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'aurapay')])
|
||||
|
||||
if test_payment_buttons:
|
||||
rows.extend(test_payment_buttons)
|
||||
|
||||
@@ -1559,6 +1559,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
Subscription.user_id == User.id,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
.correlate(User)
|
||||
.exists()
|
||||
)
|
||||
query = select(sql_func.count(User.id)).where(base_filter, ~subquery)
|
||||
@@ -1599,42 +1600,29 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
result = await db.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
if target == 'expired':
|
||||
# Истекшие подписки
|
||||
if target in ('expired', 'expired_subscribers'):
|
||||
# Истекшие подписки — исключаем юзеров с хотя бы одной активной
|
||||
now = datetime.now(UTC)
|
||||
expired_statuses = [
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
]
|
||||
query = (
|
||||
select(sql_func.count(distinct(User.id)))
|
||||
.outerjoin(Subscription, User.id == Subscription.user_id)
|
||||
has_active_sub = (
|
||||
select(Subscription.id)
|
||||
.where(
|
||||
base_filter,
|
||||
or_(
|
||||
Subscription.status.in_(expired_statuses),
|
||||
and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value),
|
||||
and_(Subscription.id == None, User.has_had_paid_subscription == True),
|
||||
),
|
||||
Subscription.user_id == User.id,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
.correlate(User)
|
||||
.exists()
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
if target == 'expired_subscribers':
|
||||
# То же что и expired
|
||||
now = datetime.now(UTC)
|
||||
expired_statuses = [
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
]
|
||||
query = (
|
||||
select(sql_func.count(distinct(User.id)))
|
||||
.outerjoin(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
base_filter,
|
||||
~has_active_sub,
|
||||
or_(
|
||||
Subscription.status.in_(expired_statuses),
|
||||
and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value),
|
||||
@@ -1785,6 +1773,9 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
for user in users:
|
||||
subs = getattr(user, 'subscriptions', None) or []
|
||||
if subs:
|
||||
has_active = any(s.is_active for s in subs)
|
||||
if has_active:
|
||||
continue # Skip users who have at least one active subscription
|
||||
has_expired = any(s.status in expired_statuses or (s.end_date <= now and not s.is_active) for s in subs)
|
||||
if has_expired:
|
||||
expired_users.append(user)
|
||||
@@ -1833,6 +1824,9 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
for user in users:
|
||||
subs = getattr(user, 'subscriptions', None) or []
|
||||
if subs:
|
||||
has_active = any(s.is_active for s in subs)
|
||||
if has_active:
|
||||
continue # Skip users who have at least one active subscription
|
||||
has_expired = any(s.status in expired_statuses or (s.end_date <= now and not s.is_active) for s in subs)
|
||||
if has_expired:
|
||||
expired_users.append(user)
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -77,7 +77,7 @@ def _build_server_edit_view(server):
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🎁 Выдавать сквад' if not server.is_trial_eligible else '🚫 Не выдавать сквад',
|
||||
text='🎁 Выдавать в триал' if not server.is_trial_eligible else '🚫 Не выдавать в триал',
|
||||
callback_data=f'admin_server_trial_{server.id}',
|
||||
),
|
||||
],
|
||||
|
||||
+124
-35
@@ -990,13 +990,14 @@ async def _render_user_subscription_overview(
|
||||
]
|
||||
)
|
||||
else:
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='✅ Активировать', callback_data=f'admin_sub_activate_{user_id}{_sid}'
|
||||
)
|
||||
]
|
||||
)
|
||||
row = [
|
||||
types.InlineKeyboardButton(text='✅ Активировать', callback_data=f'admin_sub_activate_{user_id}{_sid}'),
|
||||
]
|
||||
if settings.is_multi_tariff_enabled() and subscription_id:
|
||||
row.append(
|
||||
types.InlineKeyboardButton(text='🗑 Удалить', callback_data=f'admin_sub_delete_{user_id}{_sid}')
|
||||
)
|
||||
keyboard.append(row)
|
||||
else:
|
||||
text += '❌ <b>Подписка отсутствует</b>\n\n'
|
||||
text += 'Пользователь еще не активировал подписку.'
|
||||
@@ -3302,6 +3303,76 @@ async def confirm_subscription_deactivation(callback: types.CallbackQuery, db_us
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_user_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Show confirmation for deleting a subscription (multi-tariff only)."""
|
||||
user_id, subscription_id = _extract_admin_sub_context(callback.data)
|
||||
|
||||
if not subscription_id or not settings.is_multi_tariff_enabled():
|
||||
await callback.answer('Удаление доступно только в мультитарифном режиме', show_alert=True)
|
||||
return
|
||||
|
||||
back_cb = f'admin_user_sub_select_{user_id}_{subscription_id}'
|
||||
_sid = f'_s{subscription_id}'
|
||||
|
||||
await callback.message.edit_text(
|
||||
'🗑 <b>Удаление подписки</b>\n\n⚠️ Подписка будет полностью удалена из системы.\nЭто действие необратимо!',
|
||||
reply_markup=get_confirmation_keyboard(f'admin_sub_delete_confirm_{user_id}{_sid}', back_cb, db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_subscription_deletion(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Delete a subscription permanently (multi-tariff only)."""
|
||||
user_id, subscription_id = _extract_admin_sub_context(callback.data)
|
||||
|
||||
if not subscription_id or not settings.is_multi_tariff_enabled():
|
||||
await callback.answer('Удаление доступно только в мультитарифном режиме', show_alert=True)
|
||||
return
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_id_for_user
|
||||
|
||||
subscription = await get_subscription_by_id_for_user(db, subscription_id, user_id)
|
||||
if not subscription:
|
||||
await callback.answer('Подписка не найдена', show_alert=True)
|
||||
return
|
||||
|
||||
# Disable on Remnawave side first
|
||||
_uuid = getattr(subscription, 'remnawave_uuid', None)
|
||||
if _uuid:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(_uuid)
|
||||
|
||||
# Delete traffic purchases
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
|
||||
await db.delete(subscription)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Админ удалил подписку пользователя',
|
||||
admin_id=db_user.id,
|
||||
user_id=user_id,
|
||||
subscription_id=subscription_id,
|
||||
)
|
||||
|
||||
back_cb = f'admin_user_subscription_{user_id}'
|
||||
await callback.message.edit_text(
|
||||
'✅ Подписка удалена',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text='📱 К подпискам', callback_data=back_cb)]]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def activate_user_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
@@ -3750,26 +3821,38 @@ async def start_devices_edit(callback: types.CallbackQuery, db_user: User, state
|
||||
else f'admin_user_subscription_{user_id}'
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
'📱 <b>Изменение количества устройств</b>\n\n'
|
||||
'Введите новое количество устройств (от 1 до 10):\n'
|
||||
'• Текущее значение будет заменено\n'
|
||||
'• Примеры: 1, 2, 5, 10\n\n'
|
||||
'Или нажмите /cancel для отмены',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
max_dev = settings.MAX_DEVICES_LIMIT
|
||||
# Build device buttons dynamically: rows of 4, respect Telegram 100 button limit (99 + cancel)
|
||||
if max_dev <= 99:
|
||||
device_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
row: list[types.InlineKeyboardButton] = []
|
||||
for i in range(1, max_dev + 1):
|
||||
row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=str(i),
|
||||
callback_data=f'admin_user_devices_set_{user_id}{_sid}_{i}',
|
||||
)
|
||||
)
|
||||
if len(row) == 4:
|
||||
device_buttons.append(row)
|
||||
row = []
|
||||
if row:
|
||||
device_buttons.append(row)
|
||||
device_buttons.append([types.InlineKeyboardButton(text='❌ Отмена', callback_data=back_cb)])
|
||||
markup = types.InlineKeyboardMarkup(inline_keyboard=device_buttons)
|
||||
else:
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text='1', callback_data=f'admin_user_devices_set_{user_id}{_sid}_1'),
|
||||
types.InlineKeyboardButton(text='2', callback_data=f'admin_user_devices_set_{user_id}{_sid}_2'),
|
||||
types.InlineKeyboardButton(text='3', callback_data=f'admin_user_devices_set_{user_id}{_sid}_3'),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text='5', callback_data=f'admin_user_devices_set_{user_id}{_sid}_5'),
|
||||
types.InlineKeyboardButton(text='10', callback_data=f'admin_user_devices_set_{user_id}{_sid}_10'),
|
||||
],
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=back_cb)],
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
'📱 <b>Изменение количества устройств</b>\n\n'
|
||||
f'Введите новое количество устройств (от 1 до {max_dev}):\n'
|
||||
'• Текущее значение будет заменено\n\n'
|
||||
'Или нажмите /cancel для отмены',
|
||||
reply_markup=markup,
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_user_devices)
|
||||
@@ -3839,8 +3922,8 @@ async def process_devices_edit_text(message: types.Message, db_user: User, state
|
||||
try:
|
||||
devices = int(message.text.strip())
|
||||
|
||||
if devices <= 0 or devices > 10:
|
||||
await message.answer('❌ Количество устройств должно быть от 1 до 10')
|
||||
if devices <= 0 or devices > settings.MAX_DEVICES_LIMIT:
|
||||
await message.answer(f'❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}')
|
||||
return
|
||||
|
||||
success = await _update_user_devices(db, user_id, devices, db_user.id, subscription_id=subscription_id)
|
||||
@@ -4162,14 +4245,16 @@ async def _update_user_traffic(
|
||||
) or getattr(user, 'remnawave_uuid', None)
|
||||
if _uuid:
|
||||
try:
|
||||
from app.external.remnawave_api import TrafficLimitStrategy
|
||||
from app.services.subscription_service import get_traffic_reset_strategy
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
await api.update_user(
|
||||
uuid=_uuid,
|
||||
traffic_limit_bytes=traffic_gb * (1024**3) if traffic_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(
|
||||
subscription.tariff if subscription else None
|
||||
),
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name, username=user.username, telegram_id=user.telegram_id
|
||||
),
|
||||
@@ -4433,11 +4518,9 @@ async def _grant_paid_subscription(
|
||||
trial_squads: list[str] = []
|
||||
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(db)
|
||||
if trial_uuid:
|
||||
trial_squads = [trial_uuid]
|
||||
trial_squads = await get_effective_tariff_squad_uuids(db, None)
|
||||
except Exception as error:
|
||||
logger.error('Не удалось подобрать сквад при выдаче подписки админом', admin_id=admin_id, error=error)
|
||||
|
||||
@@ -4877,8 +4960,9 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
)
|
||||
|
||||
try:
|
||||
from app.external.remnawave_api import TrafficLimitStrategy, UserStatus
|
||||
from app.external.remnawave_api import UserStatus
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import get_traffic_reset_strategy
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
@@ -4903,7 +4987,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3)
|
||||
if subscription.traffic_limit_gb > 0
|
||||
else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
@@ -4939,7 +5023,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3)
|
||||
if subscription.traffic_limit_gb > 0
|
||||
else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email,
|
||||
description=settings.format_remnawave_user_description(
|
||||
@@ -5939,6 +6023,11 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(activate_user_subscription, F.data.startswith('admin_sub_activate_'))
|
||||
|
||||
dp.callback_query.register(
|
||||
delete_user_subscription, F.data.startswith('admin_sub_delete_') & ~F.data.contains('confirm')
|
||||
)
|
||||
dp.callback_query.register(confirm_subscription_deletion, F.data.startswith('admin_sub_delete_confirm_'))
|
||||
|
||||
dp.callback_query.register(grant_trial_subscription, F.data.startswith('admin_sub_grant_trial_'))
|
||||
|
||||
dp.callback_query.register(
|
||||
|
||||
@@ -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')
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Handler for AuraPay balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_aurapay_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method_type: str | None = None,
|
||||
):
|
||||
"""
|
||||
Common logic for creating AuraPay payment and sending response.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_aurapay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
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_aurapay_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'\U0001f4b3 Оплатить {amount}\u20bd',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'AURAPAY_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}\u20bd</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info('AuraPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_aurapay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.AURAPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.AURAPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}\u20bd',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}\u20bd',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
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(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""Common logic for starting AuraPay 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.AURAPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.AURAPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
|
||||
# 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=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'AURAPAY_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}\u20bd\n'
|
||||
'Максимум: {max_amount}\u20bd',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@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')
|
||||
@@ -149,6 +149,69 @@ async def route_payment_by_method(
|
||||
await process_severpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'paypear':
|
||||
from .paypear import process_paypear_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_paypear_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'rollypay':
|
||||
from .rollypay import process_rollypay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_rollypay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'overpay':
|
||||
from .overpay import process_overpay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_overpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
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
|
||||
|
||||
@@ -470,12 +533,16 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
|
||||
amount_rubles = float(amount_text.replace(',', '.'))
|
||||
|
||||
if amount_rubles < 1:
|
||||
await message.answer('Минимальная сумма пополнения: 1 ₽', reply_markup=get_back_keyboard(db_user.language))
|
||||
await message.answer(
|
||||
'Минимальная сумма пополнения: 1 ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
return
|
||||
|
||||
if amount_rubles > 50000:
|
||||
await message.answer(
|
||||
'Максимальная сумма пополнения: 50,000 ₽', reply_markup=get_back_keyboard(db_user.language)
|
||||
'Максимальная сумма пополнения: 50,000 ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -488,7 +555,7 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
|
||||
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(
|
||||
f'❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f} ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -496,7 +563,7 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
|
||||
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(
|
||||
f'❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f} ₽'.replace(',', ' '),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -572,6 +639,7 @@ async def handle_topup_amount_callback(
|
||||
|
||||
platega_method_code = int(method[len('platega_m') :])
|
||||
await state.update_data(payment_method='platega', platega_method=platega_method_code)
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(callback.message, db_user, db, amount_kopeks, state)
|
||||
elif method == 'platega':
|
||||
@@ -583,6 +651,7 @@ async def handle_topup_amount_callback(
|
||||
method_code = int(data.get('platega_method', 0)) if data else 0
|
||||
|
||||
if method_code > 0:
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(callback.message, db_user, db, amount_kopeks, state)
|
||||
else:
|
||||
@@ -594,9 +663,12 @@ async def handle_topup_amount_callback(
|
||||
await start_tribute_payment(callback, db_user)
|
||||
return
|
||||
# Стандартные методы через роутер
|
||||
elif not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, method):
|
||||
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
|
||||
return
|
||||
else:
|
||||
await state.update_data(payment_method=method)
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
if not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, method):
|
||||
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
@@ -719,6 +791,65 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(start_severpay_topup, F.data == 'topup_severpay')
|
||||
|
||||
from .paypear import start_paypear_topup
|
||||
|
||||
dp.callback_query.register(start_paypear_topup, F.data == 'topup_paypear')
|
||||
|
||||
from .rollypay import start_rollypay_topup
|
||||
|
||||
dp.callback_query.register(start_rollypay_topup, F.data == 'topup_rollypay')
|
||||
|
||||
from .overpay import start_overpay_topup
|
||||
|
||||
dp.callback_query.register(start_overpay_topup, F.data == 'topup_overpay')
|
||||
|
||||
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
|
||||
|
||||
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Handler for Overpay 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_overpay_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating Overpay 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_overpay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_overpay_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(
|
||||
'OVERPAY_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('Overpay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_overpay_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.OVERPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.OVERPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}\u20bd',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}\u20bd',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_overpay_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_overpay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start Overpay top-up process - ask for amount.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='overpay')
|
||||
|
||||
min_amount = settings.OVERPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.OVERPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_overpay_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(
|
||||
'OVERPAY_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}\u20bd\n'
|
||||
'Максимум: {max_amount}\u20bd',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Handler for PayPear balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_paypear_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating PayPear payment and sending response.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_paypear_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_paypear_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'\U0001f4b3 Оплатить {amount}\u20bd',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'PAYPEAR_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}\u20bd</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info('PayPear payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_paypear_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.PAYPEAR_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.PAYPEAR_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}\u20bd',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}\u20bd',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_paypear_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_paypear_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start PayPear top-up process - ask for amount.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='paypear')
|
||||
|
||||
min_amount = settings.PAYPEAR_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.PAYPEAR_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_paypear_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'PAYPEAR_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}\u20bd\n'
|
||||
'Максимум: {max_amount}\u20bd',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
@@ -42,6 +42,7 @@ async def _prompt_amount(
|
||||
# Если сумма уже известна (например, после быстрого выбора),
|
||||
# сразу создаём платеж и сбрасываем временное значение.
|
||||
await state.update_data(platega_pending_amount=None)
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
@@ -294,8 +295,9 @@ async def process_platega_payment_amount(
|
||||
'PLATEGA_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма для оплаты через Platega: {amount}',
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
|
||||
@@ -304,8 +306,9 @@ async def process_platega_payment_amount(
|
||||
'PLATEGA_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма для оплаты через Platega: {amount}',
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS)),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
return
|
||||
|
||||
try:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user