Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fbd0bff28 | |||
| dff3eb14a1 | |||
| fab3d54f24 | |||
| 2c3ffc8c8a | |||
| 6f87563789 | |||
| 7005052156 | |||
| 29ae7089aa | |||
| 80e953a07b | |||
| aebf58068f | |||
| a491fe34bd | |||
| 830e64afe0 | |||
| 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 |
+84
-1
@@ -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
|
||||
|
||||
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.45.0"
|
||||
".": "3.51.0"
|
||||
}
|
||||
|
||||
+166
@@ -1,5 +1,171 @@
|
||||
# Changelog
|
||||
|
||||
## [3.51.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.50.0...v3.51.0) (2026-04-23)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* integrate Overpay payment provider (pay.overpay.io) ([2c3ffc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2c3ffc8c8a9e6591d2356320ed0a71ab5db6cbec))
|
||||
* respond to unknown media messages (photos, videos, documents) ([29ae708](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29ae7089aa0a8a5c7ddd73622a492df6b5e67e03))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* inactive user cleanup deletes users with paid subscriptions ([7005052](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/70050521566ab86b75fb4f64ed3b61bced5d1612))
|
||||
* pad short RemnaWave usernames to meet 3-char minimum ([6f87563](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6f875637899544d95daf8d87a30042b5347f6b41))
|
||||
|
||||
## [3.50.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.49.0...v3.50.0) (2026-04-22)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook ([#2894](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2894)) ([7093d36](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7093d368d3356c692bff3eddda68996579bfd036))
|
||||
* landing page analytics goals and sticky pay button ([3272b4b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3272b4bb053c76c3995cd3904b085a28f741e815))
|
||||
* tariff switch direction control, fix device pricing within tariff limit ([9ed4f08](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ed4f086b0102c20d7861e03f4d212ff57e28245))
|
||||
* **tickets:** multi-media message gallery (media_items JSONB) ([36571c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/36571c4275b76fa0bdb490ffccacc0a0e32e9bd6))
|
||||
* v3.50.0 release ([a491fe3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a491fe34bda6da86d72891fe4d05b6abd169cf2f))
|
||||
* Yandex Metrika offline conversions + S2S postbacks ([1068c13](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1068c1387a03bf7c94c7b29f9bb61acc3e3e782a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* classic mode renewal resets device_limit to 1 via cart key mismatch ([9ca3320](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ca3320a0204346aac2c76601f221b0bc70226a7))
|
||||
* do not reset subscription_crypto_link when cryptoLink absent in webhook ([#2891](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2891)) ([b71e58c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b71e58c8d25a5c94c94a401a59359cab4870e3c0))
|
||||
* do not update first_name/last_name from OIDC claims ([#2892](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2892)) ([1696e6f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1696e6f8843ceaa1a3e9a6d536e0d99968e579b1))
|
||||
* FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal ([7be404b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7be404b918640dcccdcda82802bb02a342b730d6))
|
||||
* grant all available squads for unrestricted trials ([#2897](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2897)) ([905cea6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/905cea68b48b596412d83c42de0d1dce77dcaee0))
|
||||
* menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports ([66f8577](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/66f8577448712786d851b1802c4a183e7acc1779))
|
||||
* tariff switch pricing showing free for upgrades, admin duplicate subscription guard ([da855a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da855a7c8955dcbf3543dbbaedc00e16b9c35193))
|
||||
* ticket media_items review fixes ([dd17710](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dd177101f7314c5d25120a69e04c66d74f37b18f))
|
||||
* validate analytics goal is set when analytics is enabled on landing ([d316325](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d31632534b3e1b2268a8f8b2a441214f377f23eb))
|
||||
* устранить MissingGreenlet в автоплатежах и починить traceback в логах ([db79cc9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db79cc9eb0d7dc7a4a1ae9190f7a23c6c9e6e317))
|
||||
* устранить root cause MissingGreenlet в автоплатежах через refetch по id ([3b03c25](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3b03c253cc1603fe54fddcbc8bc374ec2c71bdfc))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.45.0" # x-release-please-version
|
||||
ARG VERSION="v3.51.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ Bedolaga — полнофункциональная платформа для п
|
||||
|
||||
### 💳 Платежи
|
||||
|
||||
- 🏦 **15 платёжных провайдеров** одновременно
|
||||
- 🏦 **18 платёжных провайдеров** одновременно
|
||||
- 💰 Единый баланс: пополнение любым способом → покупка с баланса
|
||||
- ⚡ Автопокупка подписки после пополнения
|
||||
- 💾 Рекуррентные платежи (сохранённые карты)
|
||||
@@ -123,6 +123,9 @@ Bedolaga — полнофункциональная платформа для п
|
||||
| 💳 | **MulenPay** | Карты | RUB |
|
||||
| 💳 | **RioPay** | Карты | RUB |
|
||||
| 💳 | **SeverPay** | СБП, карты | RUB |
|
||||
| 🤝 | **[PayPear](https://t.me/Paymen1_Manager)** 🔸 | Карты, СБП, SberPay, T-Pay | RUB |
|
||||
| 🤝 | **[RollyPay](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)** 🔸 | СБП, карты, крипто | RUB → USDT |
|
||||
| 🤝 | **[AuraPay](https://aurapay.tech/)** 🔸 | Карты, СБП | RUB |
|
||||
| 📲 | **Tribute** | Telegram-платежи | RUB |
|
||||
|
||||
</div>
|
||||
@@ -157,6 +160,44 @@ Bedolaga — официальный партнёр платёжной систе
|
||||
|
||||
📩 По вопросам: [@wyrz_wata](https://t.me/wyrz_wata)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр PayPear**
|
||||
|
||||
Bedolaga — официальный партнёр платёжной системы **[PayPear](https://paypear.ru)**.<br>
|
||||
Банковские карты, СБП, SberPay и T-Pay — всё через единый API.<br>
|
||||
Подключение по **спец. условиям** через кодовое слово **`БЕДОЛАГА`**
|
||||
|
||||
📩 Менеджер: [@Paymen1_Manager](https://t.me/Paymen1_Manager)
|
||||
|
||||
</td>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр RollyPay**
|
||||
|
||||
Bedolaga — официальный партнёр платёжного шлюза **[RollyPay](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)**.<br>
|
||||
СБП (от 5%), банковские карты РФ, крипто, вывод в USDT.<br>
|
||||
Универсальная форма оплаты, высокая проходимость, стабильная работа в каскаде.<br>
|
||||
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
|
||||
|
||||
📩 Менеджер: [@rollypay_manager](https://t.me/rollypay_manager) | 🌐 [rollypay.io](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр AuraPay**
|
||||
|
||||
Bedolaga — официальный партнёр платёжной системы **[AuraPay](https://aurapay.tech/)**.<br>
|
||||
Банковские карты и СБП через единый API с быстрой интеграцией.<br>
|
||||
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
|
||||
|
||||
📩 Менеджер: [@kickdownm](https://t.me/kickdownm) | 🌐 [aurapay.tech](https://aurapay.tech/)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -222,7 +263,7 @@ docker compose up -d
|
||||
| | Раздел | Описание |
|
||||
|:---:|:---|:---|
|
||||
| 🚀 | [Быстрый старт](https://docs.bedolagam.ru/getting-started/quickstart) | Развёртывание за 5 минут |
|
||||
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 14 провайдеров, webhook, фискализация |
|
||||
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 18 провайдеров, webhook, фискализация |
|
||||
| 📦 | [Подписки и тарифы](https://docs.bedolagam.ru/bot/subscriptions) | Конфигурация планов и трафика |
|
||||
| 👥 | [Реферальная программа](https://docs.bedolagam.ru/bot/referral-program) | Партнёрка и вывод средств |
|
||||
| 🖥 | [Cabinet](https://docs.bedolagam.ru/cabinet/overview) | Настройка веб-кабинета |
|
||||
|
||||
+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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -638,6 +661,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)
|
||||
@@ -1068,6 +1096,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1050,6 +1050,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
|
||||
@@ -1069,16 +1080,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)
|
||||
@@ -1191,6 +1211,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
|
||||
|
||||
@@ -1322,6 +1354,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
|
||||
@@ -3023,6 +3067,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
|
||||
|
||||
+32
-14
@@ -144,22 +144,28 @@ 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(
|
||||
@@ -809,10 +815,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()
|
||||
@@ -1052,6 +1057,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 +1072,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 +1187,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,126 @@ 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',
|
||||
)
|
||||
|
||||
else:
|
||||
# For other payment methods, redirect to bot
|
||||
raise HTTPException(
|
||||
|
||||
@@ -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 ============
|
||||
|
||||
|
||||
|
||||
@@ -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,}$')
|
||||
@@ -535,6 +540,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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -79,6 +79,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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -108,7 +108,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
|
||||
@@ -228,12 +245,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:
|
||||
@@ -354,10 +383,27 @@ async def purchase_devices(
|
||||
days_left = max(1, (end_date - now).days)
|
||||
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
|
||||
@@ -469,12 +515,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)
|
||||
|
||||
@@ -603,8 +661,25 @@ async def save_devices_cart(
|
||||
days_left = max(1, (end_date - now).days)
|
||||
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
|
||||
@@ -700,9 +775,26 @@ async def get_device_price(
|
||||
days_left = max(1, (end_date - now).days)
|
||||
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
|
||||
|
||||
@@ -353,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
|
||||
@@ -676,8 +679,10 @@ 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',
|
||||
@@ -909,6 +914,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 not subscription.remnawave_uuid else 'update',
|
||||
)
|
||||
|
||||
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
|
||||
if not is_daily_tariff:
|
||||
@@ -1210,9 +1222,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:
|
||||
@@ -1226,6 +1240,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,
|
||||
@@ -1247,6 +1268,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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -316,19 +316,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(
|
||||
@@ -604,17 +617,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}'
|
||||
|
||||
+141
-3
@@ -68,9 +68,9 @@ class Settings(BaseSettings):
|
||||
ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID: int | None = None # Партнёрки, выводы, админ-действия
|
||||
|
||||
# Настройки очереди чеков NaloGO
|
||||
NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды)
|
||||
NALOGO_QUEUE_CHECK_INTERVAL: int = 600 # Интервал проверки очереди (секунды, 10 мин)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS: int = 10 # Максимум попыток отправки чека
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS: int = 72 # Максимум попыток отправки чека (72 × 10мин = 12 часов)
|
||||
|
||||
ADMIN_REPORTS_ENABLED: bool = False
|
||||
ADMIN_REPORTS_CHAT_ID: str | None = None
|
||||
@@ -148,6 +148,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
|
||||
@@ -324,6 +327,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
|
||||
@@ -436,6 +440,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
|
||||
@@ -562,6 +567,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
|
||||
@@ -586,6 +606,59 @@ class Settings(BaseSettings):
|
||||
SEVERPAY_RETURN_URL: str | None = None
|
||||
SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320
|
||||
|
||||
# PayPear (paypear.ru)
|
||||
PAYPEAR_ENABLED: bool = False
|
||||
PAYPEAR_SHOP_ID: str | None = None
|
||||
PAYPEAR_SECRET_KEY: str | None = None
|
||||
PAYPEAR_DISPLAY_NAME: str = 'PayPear'
|
||||
PAYPEAR_CURRENCY: str = 'RUB'
|
||||
PAYPEAR_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
PAYPEAR_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
PAYPEAR_WEBHOOK_PATH: str = '/paypear-webhook'
|
||||
PAYPEAR_RETURN_URL: str | None = None
|
||||
PAYPEAR_PAYMENT_METHOD: str = 'sbp' # bank_card, sbp, sberpay, tpay
|
||||
|
||||
# RollyPay (rollypay.io)
|
||||
ROLLYPAY_ENABLED: bool = False
|
||||
ROLLYPAY_API_KEY: str | None = None # X-API-Key header
|
||||
ROLLYPAY_SIGNING_SECRET: str | None = None # HMAC webhook verification
|
||||
ROLLYPAY_DISPLAY_NAME: str = 'RollyPay'
|
||||
ROLLYPAY_CURRENCY: str = 'RUB'
|
||||
ROLLYPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
ROLLYPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
ROLLYPAY_WEBHOOK_PATH: str = '/rollypay-webhook'
|
||||
ROLLYPAY_RETURN_URL: str | None = None
|
||||
|
||||
# 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
|
||||
|
||||
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
|
||||
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
|
||||
CABINET_BUTTON_STYLE: str = ''
|
||||
@@ -730,6 +803,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
|
||||
@@ -825,6 +899,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:
|
||||
@@ -972,6 +1050,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.
|
||||
|
||||
@@ -1185,7 +1267,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]:
|
||||
@@ -1963,6 +2051,56 @@ class Settings(BaseSettings):
|
||||
def get_severpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_severpay_display_name())
|
||||
|
||||
def is_paypear_enabled(self) -> bool:
|
||||
return self.PAYPEAR_ENABLED and self.PAYPEAR_SHOP_ID is not None and self.PAYPEAR_SECRET_KEY is not None
|
||||
|
||||
def get_paypear_display_name(self) -> str:
|
||||
name = (self.PAYPEAR_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'PayPear'
|
||||
|
||||
def get_paypear_display_name_html(self) -> str:
|
||||
return html.escape(self.get_paypear_display_name())
|
||||
|
||||
def is_rollypay_enabled(self) -> bool:
|
||||
return self.ROLLYPAY_ENABLED and self.ROLLYPAY_API_KEY is not None and self.ROLLYPAY_SIGNING_SECRET is not None
|
||||
|
||||
def get_rollypay_display_name(self) -> str:
|
||||
name = (self.ROLLYPAY_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'RollyPay'
|
||||
|
||||
def get_rollypay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_rollypay_display_name())
|
||||
|
||||
def is_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_kassa_ai_sbp_enabled(self) -> bool:
|
||||
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""CRUD операции для платежей AuraPay."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import AuraPayPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_aurapay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
aurapay_invoice_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> AuraPayPayment:
|
||||
"""Создает запись о платеже AuraPay."""
|
||||
payment = AuraPayPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
aurapay_invoice_id=aurapay_invoice_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж AuraPay', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_aurapay_payment_by_order_id(db: AsyncSession, order_id: str) -> AuraPayPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(AuraPayPayment).where(AuraPayPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_aurapay_payment_by_invoice_id(db: AsyncSession, aurapay_invoice_id: str) -> AuraPayPayment | None:
|
||||
"""Получает платеж по UUID от AuraPay."""
|
||||
result = await db.execute(select(AuraPayPayment).where(AuraPayPayment.aurapay_invoice_id == aurapay_invoice_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_aurapay_payment_by_id(db: AsyncSession, payment_id: int) -> AuraPayPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(AuraPayPayment).where(AuraPayPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_aurapay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> AuraPayPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(AuraPayPayment)
|
||||
.where(AuraPayPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_aurapay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: AuraPayPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
aurapay_invoice_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> AuraPayPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if aurapay_invoice_id is not None:
|
||||
payment.aurapay_invoice_id = aurapay_invoice_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлен статус платежа AuraPay',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_aurapay_payments(db: AsyncSession, user_id: int) -> list[AuraPayPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(AuraPayPayment).where(
|
||||
AuraPayPayment.user_id == user_id,
|
||||
AuraPayPayment.status == 'pending',
|
||||
AuraPayPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_aurapay_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[AuraPayPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(AuraPayPayment).where(
|
||||
AuraPayPayment.status == 'pending',
|
||||
AuraPayPayment.is_paid == False,
|
||||
AuraPayPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_aurapay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: AuraPayPayment,
|
||||
transaction_id: int,
|
||||
) -> AuraPayPayment:
|
||||
"""Связывает платеж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -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,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
|
||||
@@ -155,6 +155,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]:
|
||||
"""Возвращает список активных серверов, доступных для подключения."""
|
||||
|
||||
|
||||
@@ -96,12 +96,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 +142,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 +151,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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -235,7 +235,12 @@ async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
TransactionType.GIFT_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
+58
-24
@@ -710,30 +710,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
|
||||
@@ -1090,6 +1112,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 +1126,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()
|
||||
+295
-2
@@ -162,6 +162,10 @@ class PaymentMethod(Enum):
|
||||
KASSA_AI = 'kassa_ai'
|
||||
RIOPAY = 'riopay'
|
||||
SEVERPAY = 'severpay'
|
||||
PAYPEAR = 'paypear'
|
||||
ROLLYPAY = 'rollypay'
|
||||
OVERPAY = 'overpay'
|
||||
AURAPAY = 'aurapay'
|
||||
MANUAL = 'manual'
|
||||
BALANCE = 'balance'
|
||||
|
||||
@@ -878,6 +882,254 @@ 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 PromoGroup(Base):
|
||||
__tablename__ = 'promo_groups'
|
||||
|
||||
@@ -1204,6 +1456,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 +1509,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 +1611,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')"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2238,6 +2492,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 +2899,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 +3522,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 +3591,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 +3676,26 @@ 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())
|
||||
|
||||
Vendored
+9
-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
|
||||
|
||||
@@ -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',
|
||||
@@ -1260,6 +1264,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)
|
||||
|
||||
@@ -1599,42 +1599,28 @@ 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,
|
||||
)
|
||||
.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 +1771,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 +1822,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)
|
||||
|
||||
@@ -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}',
|
||||
),
|
||||
],
|
||||
|
||||
+116
-30
@@ -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)
|
||||
@@ -4435,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)
|
||||
|
||||
@@ -5942,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,247 @@
|
||||
"""Handler for AuraPay balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_aurapay_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating AuraPay payment and sending response.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_aurapay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_aurapay_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'\U0001f4b3 Оплатить {amount}\u20bd',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'AURAPAY_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}\u20bd</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info('AuraPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_aurapay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.AURAPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.AURAPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}\u20bd',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}\u20bd',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_aurapay_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_aurapay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start AuraPay top-up process - ask for amount.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='aurapay')
|
||||
|
||||
min_amount = settings.AURAPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.AURAPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_aurapay_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'AURAPAY_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}\u20bd\n'
|
||||
'Максимум: {max_amount}\u20bd',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
@@ -149,6 +149,34 @@ 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 == 'aurapay':
|
||||
from .aurapay import process_aurapay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_aurapay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'riopay':
|
||||
from .riopay import process_riopay_payment_amount
|
||||
|
||||
@@ -470,12 +498,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 +520,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 +528,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 +604,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 +616,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 +628,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 +756,22 @@ 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_topup
|
||||
|
||||
dp.callback_query.register(start_aurapay_topup, F.data == 'topup_aurapay')
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
|
||||
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Handler for 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:
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Handler for RollyPay balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_rollypay_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating RollyPay payment and sending response.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_rollypay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_rollypay_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'\U0001f4b3 Оплатить {amount}\u20bd',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'ROLLYPAY_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}\u20bd</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info('RollyPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_rollypay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.ROLLYPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.ROLLYPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}\u20bd',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}\u20bd',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_rollypay_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_rollypay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start RollyPay top-up process - ask for amount.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='rollypay')
|
||||
|
||||
min_amount = settings.ROLLYPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.ROLLYPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_rollypay_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'ROLLYPAY_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}\u20bd\n'
|
||||
'Максимум: {max_amount}\u20bd',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
@@ -127,3 +127,12 @@ def register_handlers(dp: Dispatcher):
|
||||
F.text.is_not(None),
|
||||
~F.text.startswith('/'),
|
||||
)
|
||||
|
||||
# Ловим медиа-сообщения (фото, видео, документы, стикеры и т.д.)
|
||||
# без активного состояния — чтобы пользователь знал, что бот не принял медиа
|
||||
dp.message.register(
|
||||
handle_unknown_message,
|
||||
StateFilter(None),
|
||||
F.successful_payment.is_(None),
|
||||
F.text.is_(None),
|
||||
)
|
||||
|
||||
@@ -169,7 +169,9 @@ async def show_main_menu(
|
||||
await db.commit()
|
||||
|
||||
# Multi-tariff aware: check if user has ANY active subscription
|
||||
has_active_subscription = any(sub.is_active for sub in (getattr(db_user, 'subscriptions', None) or []))
|
||||
# 'limited' (traffic exhausted) subscriptions are still active for UI purposes
|
||||
_subs = getattr(db_user, 'subscriptions', None) or []
|
||||
has_active_subscription = any(sub.is_active or getattr(sub, 'actual_status', None) == 'limited' for sub in _subs)
|
||||
subscription_is_active = has_active_subscription
|
||||
|
||||
menu_text = await get_main_menu_text(db_user, texts, db)
|
||||
@@ -1013,7 +1015,9 @@ async def handle_back_to_menu(callback: types.CallbackQuery, state: FSMContext,
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Multi-tariff aware: check if user has ANY active subscription
|
||||
has_active_subscription = any(sub.is_active for sub in (getattr(db_user, 'subscriptions', None) or []))
|
||||
# 'limited' (traffic exhausted) subscriptions are still active for UI purposes
|
||||
_subs = getattr(db_user, 'subscriptions', None) or []
|
||||
has_active_subscription = any(sub.is_active or getattr(sub, 'actual_status', None) == 'limited' for sub in _subs)
|
||||
subscription_is_active = has_active_subscription
|
||||
|
||||
menu_text = await get_main_menu_text(db_user, texts, db)
|
||||
|
||||
@@ -197,6 +197,8 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
|
||||
error_messages = {
|
||||
'not_found': texts.PROMOCODE_INVALID,
|
||||
'expired': texts.PROMOCODE_EXPIRED,
|
||||
'inactive': texts.t('PROMOCODE_INACTIVE', '❌ Промокод деактивирован'),
|
||||
'not_yet_valid': texts.t('PROMOCODE_NOT_YET_VALID', '❌ Промокод ещё не начал действовать'),
|
||||
'used': texts.PROMOCODE_USED,
|
||||
'already_used_by_user': texts.PROMOCODE_USED,
|
||||
'not_first_purchase': texts.t(
|
||||
|
||||
@@ -549,6 +549,14 @@ async def handle_simple_subscription_pay_with_balance(
|
||||
sync_error=sync_error,
|
||||
exc_info=True,
|
||||
)
|
||||
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',
|
||||
)
|
||||
|
||||
# Отправляем уведомление об успешной покупке
|
||||
server_label = _get_simple_subscription_server_label(
|
||||
@@ -2289,6 +2297,14 @@ async def confirm_simple_subscription_purchase(
|
||||
sync_error=sync_error,
|
||||
exc_info=True,
|
||||
)
|
||||
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',
|
||||
)
|
||||
|
||||
# Отправляем уведомление об успешной покупке
|
||||
server_label = _get_simple_subscription_server_label(
|
||||
|
||||
@@ -253,6 +253,14 @@ async def _handle_trial_payment(
|
||||
except Exception as rw_error:
|
||||
logger.error('Ошибка создания пользователя RemnaWave для триала', rw_error=rw_error)
|
||||
# Не откатываем подписку, просто логируем - RemnaWave может быть временно недоступен
|
||||
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',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
+23
-2
@@ -234,6 +234,14 @@ async def _claim_phantom_user(
|
||||
subscription_id=phantom_sub.id,
|
||||
error=str(exc),
|
||||
)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
if hasattr(phantom_sub, 'id') and hasattr(phantom_sub, 'user_id'):
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=phantom_sub.id,
|
||||
user_id=phantom_sub.user_id,
|
||||
action='update',
|
||||
)
|
||||
|
||||
return True, phantom
|
||||
|
||||
@@ -336,8 +344,9 @@ def _calculate_subscription_flags(subscription):
|
||||
return False, False
|
||||
|
||||
actual_status = getattr(subscription, 'actual_status', None)
|
||||
has_active_subscription = actual_status in {'active', 'trial'}
|
||||
subscription_is_active = bool(getattr(subscription, 'is_active', False))
|
||||
# 'limited' subscriptions are still active (traffic exhausted, but subscription not expired)
|
||||
has_active_subscription = actual_status in {'active', 'trial', 'limited'}
|
||||
subscription_is_active = bool(getattr(subscription, 'is_active', False)) or actual_status == 'limited'
|
||||
|
||||
return has_active_subscription, subscription_is_active
|
||||
|
||||
@@ -2443,6 +2452,18 @@ async def required_sub_channel_check(
|
||||
telegram_id=user.telegram_id if user else query.from_user.id,
|
||||
api_error=api_error,
|
||||
)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
for sub in _subs:
|
||||
if sub.is_trial and sub.status == SubscriptionStatus.ACTIVE.value:
|
||||
if hasattr(sub, 'id') and hasattr(sub, 'user_id'):
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=sub.id,
|
||||
user_id=sub.user_id,
|
||||
action='update'
|
||||
if (getattr(sub, 'remnawave_uuid', None) or user.remnawave_uuid)
|
||||
else 'create',
|
||||
)
|
||||
|
||||
await query.answer(
|
||||
texts.t('CHANNEL_SUBSCRIBE_THANKS', '✅ Спасибо за подписку'),
|
||||
|
||||
@@ -412,7 +412,18 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
|
||||
await db.commit()
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
|
||||
try:
|
||||
await subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
|
||||
except Exception as rw_err:
|
||||
logger.error('Ошибка синхронизации с RemnaWave при смене стран', error=rw_err)
|
||||
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='update',
|
||||
)
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
|
||||
@@ -325,9 +325,14 @@ async def confirm_change_devices(
|
||||
if devices_difference > 0:
|
||||
additional_devices = devices_difference
|
||||
|
||||
# Для тарифов - все устройства платные (нет бесплатного лимита)
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
if tariff:
|
||||
chargeable_devices = additional_devices
|
||||
tariff_included = tariff.device_limit or 0
|
||||
if current_devices < tariff_included:
|
||||
free_devices = tariff_included - current_devices
|
||||
chargeable_devices = max(0, additional_devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = additional_devices
|
||||
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
|
||||
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
|
||||
chargeable_devices = max(0, additional_devices - free_devices)
|
||||
@@ -352,7 +357,8 @@ async def confirm_change_devices(
|
||||
)
|
||||
# Цена = месячная_цена * days_left / 30
|
||||
price = int(discounted_per_month * days_left / 30)
|
||||
price = max(100, price) # Минимум 1 рубль
|
||||
if chargeable_devices > 0:
|
||||
price = max(100, price) # Минимум 1 рубль (только для платных устройств)
|
||||
total_discount = int(discount_per_month * days_left / 30)
|
||||
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
|
||||
|
||||
@@ -553,7 +559,12 @@ async def execute_change_devices(
|
||||
devices_difference = new_devices_count - current_devices
|
||||
if devices_difference > 0:
|
||||
if tariff:
|
||||
chargeable_devices = devices_difference
|
||||
tariff_included = tariff.device_limit or 0
|
||||
if current_devices < tariff_included:
|
||||
free_devices = tariff_included - current_devices
|
||||
chargeable_devices = max(0, devices_difference - free_devices)
|
||||
else:
|
||||
chargeable_devices = devices_difference
|
||||
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
|
||||
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
|
||||
chargeable_devices = max(0, devices_difference - free_devices)
|
||||
@@ -572,7 +583,8 @@ async def execute_change_devices(
|
||||
devices_discount_percent,
|
||||
)
|
||||
price = int(discounted_per_month * days_left / 30)
|
||||
price = max(100, price)
|
||||
if chargeable_devices > 0:
|
||||
price = max(100, price)
|
||||
else:
|
||||
price = 0
|
||||
|
||||
@@ -1215,7 +1227,22 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
)
|
||||
return
|
||||
|
||||
devices_price_per_month = devices_count * price_per_device
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
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, devices_count - free_devices)
|
||||
else:
|
||||
chargeable_devices = devices_count
|
||||
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
|
||||
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
|
||||
chargeable_devices = max(0, devices_count - free_devices)
|
||||
else:
|
||||
chargeable_devices = devices_count
|
||||
|
||||
devices_price_per_month = chargeable_devices * price_per_device
|
||||
|
||||
# TOCTOU: lock user row before reading promo/discount state
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
@@ -1240,7 +1267,8 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
)
|
||||
# Цена = месячная_цена * days_left / 30
|
||||
price = int(discounted_per_month * days_left / 30)
|
||||
price = max(100, price) # Минимум 1 рубль
|
||||
if chargeable_devices > 0:
|
||||
price = max(100, price) # Минимум 1 рубль (только для платных устройств)
|
||||
total_discount = int(discount_per_month * days_left / 30)
|
||||
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
|
||||
else:
|
||||
@@ -1260,7 +1288,8 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
)
|
||||
# Цена = месячная_цена * days_left / 30
|
||||
price = int(discounted_per_month * days_left / 30)
|
||||
price = max(100, price) # Минимум 1 рубль
|
||||
if chargeable_devices > 0:
|
||||
price = max(100, price) # Минимум 1 рубль (только для платных устройств)
|
||||
total_discount = int(discount_per_month * days_left / 30)
|
||||
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
|
||||
|
||||
|
||||
@@ -922,9 +922,11 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
|
||||
|
||||
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:
|
||||
@@ -937,6 +939,13 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа', 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 []
|
||||
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
db_user.id,
|
||||
@@ -1572,11 +1581,15 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext,
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_value = prepared_cart_data.get('traffic_gb')
|
||||
if traffic_value is None:
|
||||
traffic_value = prepared_cart_data.get('traffic_limit_gb')
|
||||
if traffic_value is None:
|
||||
traffic_value = settings.get_fixed_traffic_limit()
|
||||
traffic_display = 'Безлимитный' if traffic_value == 0 else f'{traffic_value} ГБ'
|
||||
else:
|
||||
traffic_value = prepared_cart_data.get('traffic_gb', 0) or 0
|
||||
traffic_value = prepared_cart_data.get('traffic_gb')
|
||||
if traffic_value is None:
|
||||
traffic_value = prepared_cart_data.get('traffic_limit_gb', 0)
|
||||
traffic_display = 'Безлимитный' if traffic_value == 0 else f'{traffic_value} ГБ'
|
||||
|
||||
summary_lines = [
|
||||
@@ -1589,6 +1602,8 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext,
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
devices_value = prepared_cart_data.get('devices')
|
||||
if devices_value is None:
|
||||
devices_value = prepared_cart_data.get('device_limit')
|
||||
if devices_value is not None:
|
||||
summary_lines.append(f'📱 Устройства: {devices_value}')
|
||||
|
||||
@@ -1735,8 +1750,8 @@ async def handle_extend_subscription(
|
||||
# original = price before ALL discounts, final = price with all discounts
|
||||
total_original_price = pricing.original_total
|
||||
|
||||
# Пропускаем периоды с нулевой ценой — защита от бесплатного продления
|
||||
if pricing.final_total <= 0 and pricing.base_price <= 0:
|
||||
# Пропускаем периоды с нулевой ценой (если оригинальная цена тоже 0 — не настроен)
|
||||
if pricing.final_total <= 0 and pricing.original_total <= 0:
|
||||
continue
|
||||
|
||||
renewal_prices[days] = {
|
||||
@@ -1963,7 +1978,10 @@ async def confirm_extend_subscription(
|
||||
'description': f'Продление подписки на {days} дней',
|
||||
'consume_promo_offer': bool(promo_offer_discount > 0),
|
||||
'device_limit': device_limit,
|
||||
'devices': device_limit,
|
||||
'traffic_limit_gb': renewal_traffic_gb,
|
||||
'traffic_gb': renewal_traffic_gb,
|
||||
'countries': list(subscription.connected_squads or []),
|
||||
}
|
||||
|
||||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||||
@@ -2218,7 +2236,12 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
devices_selection_enabled = settings.is_devices_selection_enabled()
|
||||
forced_disabled_limit: int | None = None
|
||||
if devices_selection_enabled:
|
||||
devices_selected = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
# Для extend-корзины ключ может быть 'device_limit' вместо 'devices'
|
||||
devices_selected = data.get('devices')
|
||||
if devices_selected is None:
|
||||
devices_selected = data.get('device_limit')
|
||||
if devices_selected is None:
|
||||
devices_selected = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_disabled_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_disabled_limit is None:
|
||||
@@ -2579,17 +2602,19 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
# При покупке подписки ВСЕГДА сбрасываем трафик в панели
|
||||
_purchase_uuid = (
|
||||
subscription.remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
|
||||
else db_user.remnawave_uuid
|
||||
)
|
||||
if settings.is_multi_tariff_enabled() and not getattr(subscription, 'remnawave_uuid', None):
|
||||
logger.warning(
|
||||
'Multi-tariff: subscription missing remnawave_uuid, using user fallback',
|
||||
subscription_id=getattr(subscription, 'id', None),
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(db_user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='покупка подписки',
|
||||
)
|
||||
if _purchase_uuid:
|
||||
else:
|
||||
remnawave_user = await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
@@ -2597,22 +2622,25 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
reset_reason='покупка подписки',
|
||||
sync_squads=True,
|
||||
)
|
||||
else:
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='покупка подписки',
|
||||
)
|
||||
|
||||
if not remnawave_user:
|
||||
logger.error('Не удалось создать/обновить RemnaWave пользователя для', telegram_id=db_user.telegram_id)
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='покупка подписки (повторная попытка)',
|
||||
)
|
||||
logger.error('Не удалось создать/обновить RemnaWave пользователя', telegram_id=db_user.telegram_id)
|
||||
try:
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='покупка подписки (повторная попытка)',
|
||||
)
|
||||
except Exception as retry_error:
|
||||
logger.error('Повторная попытка создания RemnaWave пользователя не удалась', error=retry_error)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=db_user.id,
|
||||
action='create',
|
||||
)
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
@@ -3162,6 +3190,13 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка синхронизации с Remnawave при возобновлении', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=db_user.id,
|
||||
action='update',
|
||||
)
|
||||
|
||||
# Отправляем уведомление администраторам о возобновлении суточной подписки
|
||||
if resume_transaction is not None:
|
||||
@@ -3288,9 +3323,11 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await _get_tariff(db, trial_tariff_id)
|
||||
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:
|
||||
@@ -3303,6 +3340,13 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа для платного триала', 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 []
|
||||
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
db_user.id,
|
||||
@@ -3647,9 +3691,11 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await _get_tariff(db, trial_tariff_id)
|
||||
if trial_tariff:
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
|
||||
trial_traffic = trial_tariff.traffic_limit_gb
|
||||
trial_devices = trial_tariff.device_limit
|
||||
trial_squads_list = trial_tariff.allowed_squads or []
|
||||
trial_squads_list = 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:
|
||||
@@ -3662,7 +3708,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
|
||||
|
||||
# Если тариф не задал серверы, получаем случайный сквад
|
||||
# Если триальный тариф не найден, используем legacy fallback со случайным сквадом.
|
||||
if not trial_squads_list:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
@@ -4480,8 +4526,11 @@ async def _extend_existing_subscription(
|
||||
'return_to_cart': True,
|
||||
'description': f'Продление подписки на {period_days} дней',
|
||||
'device_limit': device_limit,
|
||||
'devices': device_limit,
|
||||
'traffic_limit_gb': traffic_limit_gb,
|
||||
'traffic_gb': traffic_limit_gb,
|
||||
'squad_uuid': squad_uuid,
|
||||
'countries': [squad_uuid] if squad_uuid else [],
|
||||
'consume_promo_offer': consume_promo,
|
||||
}
|
||||
|
||||
@@ -4594,6 +4643,13 @@ async def _extend_existing_subscription(
|
||||
logger.error('⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE')
|
||||
except Exception as e:
|
||||
logger.error('⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=current_subscription.id,
|
||||
user_id=db_user.id,
|
||||
action='update',
|
||||
)
|
||||
|
||||
# Создаём транзакцию
|
||||
transaction = await create_transaction(
|
||||
|
||||
@@ -928,8 +928,8 @@ async def handle_custom_confirm(
|
||||
)
|
||||
total_price = result.final_total
|
||||
|
||||
# Проверяем, что цена за период валидна
|
||||
if result.base_price == 0 and not tariff.can_purchase_custom_days():
|
||||
# Проверяем, что цена за период валидна (original_total — цена до скидок)
|
||||
if result.original_total == 0 and not tariff.can_purchase_custom_days():
|
||||
await callback.answer('Выбранный период недоступен для этого тарифа', show_alert=True)
|
||||
return
|
||||
|
||||
@@ -1057,14 +1057,34 @@ async def handle_custom_confirm(
|
||||
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='покупка тарифа',
|
||||
)
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(db_user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='покупка тарифа',
|
||||
)
|
||||
else:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='покупка тарифа',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обновления Remnawave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=db_user.id,
|
||||
action='create',
|
||||
)
|
||||
|
||||
# Создаем транзакцию
|
||||
await create_transaction(
|
||||
@@ -1568,14 +1588,37 @@ async def confirm_tariff_purchase(
|
||||
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='покупка тарифа',
|
||||
)
|
||||
# In multi-tariff mode, each subscription has its own panel user.
|
||||
# A new subscription has no remnawave_uuid yet, so always CREATE.
|
||||
# In single-tariff mode, reuse the user-level UUID if available.
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(db_user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='покупка тарифа',
|
||||
)
|
||||
else:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='покупка тарифа',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обновления Remnawave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=db_user.id,
|
||||
action='create',
|
||||
)
|
||||
|
||||
# Создаем транзакцию
|
||||
try:
|
||||
@@ -1828,14 +1871,34 @@ async def confirm_daily_tariff_purchase(
|
||||
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='покупка суточного тарифа',
|
||||
)
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(db_user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='покупка суточного тарифа',
|
||||
)
|
||||
else:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='покупка суточного тарифа',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обновления Remnawave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=db_user.id,
|
||||
action='create',
|
||||
)
|
||||
|
||||
# Создаем транзакцию
|
||||
await create_transaction(
|
||||
@@ -2042,8 +2105,36 @@ async def show_tariff_extend(
|
||||
subscription = None
|
||||
else:
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not subscription or not subscription.tariff_id:
|
||||
await callback.answer('Тариф не найден', show_alert=True)
|
||||
if not subscription:
|
||||
await callback.answer('Подписка не найдена', show_alert=True)
|
||||
return
|
||||
|
||||
if not subscription.tariff_id:
|
||||
# Legacy user without tariff — show tariff selection for upgrade
|
||||
promo_group_id = getattr(db_user, 'promo_group_id', None)
|
||||
tariffs = await get_tariffs_for_user(db, promo_group_id)
|
||||
if not tariffs:
|
||||
await callback.answer('Нет доступных тарифов', show_alert=True)
|
||||
return
|
||||
|
||||
keyboard = []
|
||||
for t in tariffs:
|
||||
if t.is_daily:
|
||||
continue
|
||||
keyboard.append([InlineKeyboardButton(text=f'📦 {t.name}', callback_data=f'tariff_select:{t.id}')])
|
||||
if not keyboard:
|
||||
await callback.answer('Нет доступных тарифов для продления', show_alert=True)
|
||||
return
|
||||
keyboard.append([InlineKeyboardButton(text='◀️ Назад', callback_data='back_to_menu')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
'🔄 <b>Выберите тариф для продления</b>\n\n'
|
||||
'Для продления подписки необходимо выбрать тариф.\n'
|
||||
'Подписка будет обновлена с параметрами выбранного тарифа.',
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
@@ -2051,6 +2142,31 @@ async def show_tariff_extend(
|
||||
await callback.answer('Тариф не найден', show_alert=True)
|
||||
return
|
||||
|
||||
# Скрытый/неактивный тариф (например, триальный после промокода) —
|
||||
# показываем список доступных тарифов вместо продления скрытого
|
||||
if not tariff.is_active:
|
||||
promo_group_id = getattr(db_user, 'promo_group_id', None)
|
||||
tariffs = await get_tariffs_for_user(db, promo_group_id)
|
||||
active_tariffs = [t for t in tariffs if not t.is_daily]
|
||||
if not active_tariffs:
|
||||
await callback.answer('Нет доступных тарифов для продления', show_alert=True)
|
||||
return
|
||||
|
||||
keyboard = []
|
||||
for t in active_tariffs:
|
||||
keyboard.append([InlineKeyboardButton(text=f'📦 {t.name}', callback_data=f'tariff_select:{t.id}')])
|
||||
keyboard.append([InlineKeyboardButton(text='◀️ Назад', callback_data='back_to_menu')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
'🔄 <b>Выберите тариф для продления</b>\n\n'
|
||||
'Для продления подписки необходимо выбрать тариф.\n'
|
||||
'Подписка будет обновлена с параметрами выбранного тарифа.',
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||||
|
||||
# Проверяем есть ли у пользователя скидки по периодам
|
||||
@@ -2280,14 +2396,34 @@ async def confirm_tariff_extend(
|
||||
# Обновляем пользователя в Remnawave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT or was_trial,
|
||||
reset_reason='конвертация триала' if was_trial else 'продление тарифа',
|
||||
)
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(db_user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT or was_trial,
|
||||
reset_reason='конвертация триала' if was_trial else 'продление тарифа',
|
||||
)
|
||||
else:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT or was_trial,
|
||||
reset_reason='конвертация триала' if was_trial else 'продление тарифа',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обновления Remnawave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=db_user.id,
|
||||
action='create',
|
||||
)
|
||||
|
||||
# Создаем транзакцию
|
||||
await create_transaction(
|
||||
@@ -2530,6 +2666,18 @@ async def show_tariff_switch_list(
|
||||
|
||||
current_tariff_id = subscription.tariff_id
|
||||
|
||||
# Проверяем, разрешена ли смена тарифа хотя бы в одном направлении
|
||||
if not settings.TARIFF_SWITCH_UPGRADE_ENABLED and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
await callback.message.edit_text(
|
||||
'🚫 <b>Смена тарифа недоступна</b>\n\nАдминистратор отключил возможность смены тарифа.',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]]
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Получаем доступные тарифы
|
||||
promo_group_id = getattr(db_user, 'promo_group_id', None)
|
||||
tariffs = await get_tariffs_for_user(db, promo_group_id)
|
||||
@@ -2542,6 +2690,14 @@ async def show_tariff_switch_list(
|
||||
else:
|
||||
available_tariffs = [t for t in tariffs if t.id != current_tariff_id]
|
||||
|
||||
# Фильтруем по разрешённым направлениям (upgrade/downgrade)
|
||||
current_tariff = await get_tariff_by_id(db, current_tariff_id) if current_tariff_id else None
|
||||
if current_tariff:
|
||||
remaining_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0
|
||||
available_tariffs = _filter_tariffs_by_switch_direction(
|
||||
available_tariffs, current_tariff, remaining_days, db_user
|
||||
)
|
||||
|
||||
if not available_tariffs:
|
||||
await callback.message.edit_text(
|
||||
'😔 <b>Нет доступных тарифов для переключения</b>\n\nВы уже используете единственный доступный тариф.',
|
||||
@@ -2603,6 +2759,24 @@ async def select_tariff_switch(
|
||||
await callback.answer('Тариф недоступен', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем разрешение на смену в данном направлении
|
||||
current_subscription_sw, _sw_sub_id_check = await _resolve_subscription(callback, db_user, db, state)
|
||||
if current_subscription_sw and current_subscription_sw.tariff_id:
|
||||
cur_tariff_sw = await get_tariff_by_id(db, current_subscription_sw.tariff_id)
|
||||
if cur_tariff_sw:
|
||||
rem_days = (
|
||||
max(0, (current_subscription_sw.end_date - datetime.now(UTC)).days)
|
||||
if current_subscription_sw.end_date
|
||||
else 0
|
||||
)
|
||||
_, is_up = _calculate_instant_switch_cost(cur_tariff_sw, tariff, rem_days, db_user)
|
||||
if is_up and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
|
||||
await callback.answer('Повышение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
if not is_up and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
await callback.answer('Понижение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
|
||||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||||
|
||||
# Проверяем, суточный ли это тариф
|
||||
@@ -2824,6 +2998,19 @@ async def confirm_tariff_switch(
|
||||
await callback.answer('У вас нет активной подписки', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем разрешение на смену в данном направлении
|
||||
if subscription.tariff_id and subscription.tariff_id != tariff_id:
|
||||
cur_tariff_obj = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if cur_tariff_obj:
|
||||
rem_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0
|
||||
_, is_up = _calculate_instant_switch_cost(cur_tariff_obj, tariff, rem_days, db_user)
|
||||
if is_up and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
|
||||
await callback.answer('Повышение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
if not is_up and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
await callback.answer('Понижение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
|
||||
# Calculate price via PricingEngine (handles per-category discounts + extra devices)
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
@@ -2893,14 +3080,34 @@ async def confirm_tariff_switch(
|
||||
# Обновляем пользователя в Remnawave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
|
||||
reset_reason='переключение тарифа',
|
||||
)
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(db_user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
|
||||
reset_reason='переключение тарифа',
|
||||
)
|
||||
else:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
|
||||
reset_reason='переключение тарифа',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обновления Remnawave при переключении тарифа', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=db_user.id,
|
||||
action='create',
|
||||
)
|
||||
|
||||
# Гарантированный сброс устройств при смене тарифа
|
||||
await db.refresh(db_user)
|
||||
@@ -3056,6 +3263,19 @@ async def confirm_daily_tariff_switch(
|
||||
await callback.answer('У вас нет активной подписки', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем разрешение на смену в данном направлении
|
||||
if subscription.tariff_id and subscription.tariff_id != tariff_id:
|
||||
cur_tariff_daily = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if cur_tariff_daily:
|
||||
rem_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0
|
||||
_, is_up = _calculate_instant_switch_cost(cur_tariff_daily, tariff, rem_days, db_user)
|
||||
if is_up and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
|
||||
await callback.answer('Повышение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
if not is_up and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
await callback.answer('Понижение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
@@ -3121,14 +3341,34 @@ async def confirm_daily_tariff_switch(
|
||||
# Обновляем пользователя в Remnawave (сброс трафика по админ-настройке)
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
|
||||
reset_reason='смена на суточный тариф',
|
||||
)
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(db_user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
|
||||
reset_reason='смена на суточный тариф',
|
||||
)
|
||||
else:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
|
||||
reset_reason='смена на суточный тариф',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обновления Remnawave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=db_user.id,
|
||||
action='create',
|
||||
)
|
||||
|
||||
# Гарантированный сброс устройств при смене тарифа
|
||||
await db.refresh(db_user)
|
||||
@@ -3267,6 +3507,30 @@ def _calculate_instant_switch_cost(
|
||||
return result.upgrade_cost, result.is_upgrade
|
||||
|
||||
|
||||
def _filter_tariffs_by_switch_direction(
|
||||
tariffs: list[Tariff],
|
||||
current_tariff: Tariff,
|
||||
remaining_days: int,
|
||||
db_user: User | None = None,
|
||||
) -> list[Tariff]:
|
||||
"""Фильтрует тарифы по разрешённым направлениям смены (upgrade/downgrade)."""
|
||||
upgrade_ok = settings.TARIFF_SWITCH_UPGRADE_ENABLED
|
||||
downgrade_ok = settings.TARIFF_SWITCH_DOWNGRADE_ENABLED
|
||||
|
||||
if upgrade_ok and downgrade_ok:
|
||||
return tariffs
|
||||
|
||||
filtered = []
|
||||
for tariff in tariffs:
|
||||
if tariff.id == current_tariff.id:
|
||||
filtered.append(tariff)
|
||||
continue
|
||||
_, is_upgrade = _calculate_instant_switch_cost(current_tariff, tariff, remaining_days, db_user)
|
||||
if (is_upgrade and upgrade_ok) or (not is_upgrade and downgrade_ok):
|
||||
filtered.append(tariff)
|
||||
return filtered
|
||||
|
||||
|
||||
def format_instant_switch_list_text(
|
||||
tariffs: list[Tariff],
|
||||
current_tariff: Tariff,
|
||||
@@ -3274,16 +3538,21 @@ def format_instant_switch_list_text(
|
||||
db_user: User | None = None,
|
||||
) -> str:
|
||||
"""Форматирует текст со списком тарифов для мгновенного переключения."""
|
||||
upgrade_ok = settings.TARIFF_SWITCH_UPGRADE_ENABLED
|
||||
downgrade_ok = settings.TARIFF_SWITCH_DOWNGRADE_ENABLED
|
||||
|
||||
lines = [
|
||||
'📦 <b>Мгновенная смена тарифа</b>',
|
||||
f'📌 Текущий: <b>{html.escape(current_tariff.name)}</b>',
|
||||
f'⏰ Осталось: <b>{remaining_days} дн.</b>',
|
||||
'',
|
||||
'💡 При переключении остаток дней сохраняется.',
|
||||
'⬆️ Повышение тарифа = доплата за разницу',
|
||||
'⬇️ Понижение = бесплатно',
|
||||
'',
|
||||
]
|
||||
if upgrade_ok:
|
||||
lines.append('⬆️ Повышение тарифа = доплата за разницу')
|
||||
if downgrade_ok:
|
||||
lines.append('⬇️ Понижение = бесплатно')
|
||||
lines.append('')
|
||||
|
||||
for tariff in tariffs:
|
||||
if tariff.id == current_tariff.id:
|
||||
@@ -3415,6 +3684,18 @@ async def show_instant_switch_list(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Проверяем, разрешена ли смена тарифа хотя бы в одном направлении
|
||||
if not settings.TARIFF_SWITCH_UPGRADE_ENABLED and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
await callback.message.edit_text(
|
||||
'🚫 <b>Смена тарифа недоступна</b>\n\nАдминистратор отключил возможность смены тарифа.',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]]
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Получаем доступные тарифы
|
||||
promo_group_id = getattr(db_user, 'promo_group_id', None)
|
||||
tariffs = await get_tariffs_for_user(db, promo_group_id)
|
||||
@@ -3427,6 +3708,9 @@ async def show_instant_switch_list(
|
||||
else:
|
||||
available_tariffs = [t for t in tariffs if t.id != current_tariff.id]
|
||||
|
||||
# Фильтруем по разрешённым направлениям (upgrade/downgrade)
|
||||
available_tariffs = _filter_tariffs_by_switch_direction(available_tariffs, current_tariff, remaining_days, db_user)
|
||||
|
||||
if not available_tariffs:
|
||||
await callback.message.edit_text(
|
||||
'😔 <b>Нет доступных тарифов для переключения</b>\n\nВы уже используете единственный доступный тариф.',
|
||||
@@ -3496,6 +3780,14 @@ async def preview_instant_switch(
|
||||
# Рассчитываем стоимость переключения
|
||||
upgrade_cost, is_upgrade = _calculate_instant_switch_cost(current_tariff, new_tariff, remaining_days, db_user)
|
||||
|
||||
# Проверяем разрешение на смену в данном направлении
|
||||
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
|
||||
await callback.answer('Повышение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
await callback.answer('Понижение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем баланс
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
|
||||
@@ -3670,6 +3962,14 @@ async def confirm_instant_switch(
|
||||
is_upgrade = switch_result.is_upgrade
|
||||
consume_promo = switch_result.offer_discount_pct > 0
|
||||
|
||||
# Проверяем разрешение на смену в данном направлении
|
||||
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
|
||||
await callback.answer('Повышение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
await callback.answer('Понижение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем баланс если это upgrade (use locked user's fresh balance)
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
if is_upgrade and user_balance < upgrade_cost:
|
||||
@@ -3795,14 +4095,34 @@ async def confirm_instant_switch(
|
||||
# Обновляем пользователя в Remnawave (сброс трафика по админ-настройке)
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
|
||||
reset_reason='мгновенное переключение тарифа',
|
||||
)
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(db_user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
|
||||
reset_reason='мгновенное переключение тарифа',
|
||||
)
|
||||
else:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
|
||||
reset_reason='мгновенное переключение тарифа',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обновления Remnawave при мгновенном переключении', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=db_user.id,
|
||||
action='create',
|
||||
)
|
||||
|
||||
# Гарантированный сброс устройств при смене тарифа
|
||||
await db.refresh(db_user)
|
||||
|
||||
@@ -983,7 +983,14 @@ async def close_ticket_notification(callback: types.CallbackQuery, db_user: User
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await callback.message.delete()
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except TelegramBadRequest:
|
||||
# Message is too old to delete (>48h) — edit it instead
|
||||
try:
|
||||
await callback.message.edit_text(texts.t('NOTIFICATION_CLOSED', 'Уведомление закрыто.'))
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
await callback.answer(texts.t('NOTIFICATION_CLOSED', 'Уведомление закрыто.'))
|
||||
|
||||
|
||||
|
||||
+50
-2
@@ -1807,6 +1807,54 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_paypear_enabled():
|
||||
paypear_name = settings.get_paypear_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_PAYPEAR', f'💳 Оплата ({paypear_name})'),
|
||||
callback_data=_build_callback('paypear'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_rollypay_enabled():
|
||||
rollypay_name = settings.get_rollypay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ROLLYPAY', f'💳 {rollypay_name}'),
|
||||
callback_data=_build_callback('rollypay'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_overpay_enabled():
|
||||
overpay_name = settings.get_overpay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_OVERPAY', f'💳 {overpay_name}'),
|
||||
callback_data=_build_callback('overpay'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_aurapay_enabled():
|
||||
aurapay_name = settings.get_aurapay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_AURAPAY', f'💳 {aurapay_name}'),
|
||||
callback_data=_build_callback('aurapay'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_support_topup_enabled():
|
||||
keyboard.append(
|
||||
[
|
||||
@@ -2274,8 +2322,8 @@ def get_change_devices_keyboard(
|
||||
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
|
||||
if tariff and tariff_device_price:
|
||||
device_price_per_month = tariff_device_price
|
||||
# Для тарифов все устройства платные (нет бесплатного лимита)
|
||||
default_device_limit = 0
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
default_device_limit = tariff.device_limit if tariff else 0
|
||||
else:
|
||||
device_price_per_month = settings.PRICE_PER_DEVICE
|
||||
default_device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
@@ -1756,5 +1756,7 @@
|
||||
"WEBHOOK_DEVICE_ADDED": "📱 <b>New device</b>\n\nA new device has been added to your subscription{tariff_label}: <code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED": "📱 <b>Device removed</b>\n\nA device has been removed from your subscription{tariff_label}: <code>{device}</code>",
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Torrent detected</b>\n\nTorrent traffic was detected on your connection{tariff_label}. Using torrents may result in subscription restrictions.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Close"
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Close",
|
||||
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Traffic Warning</b>\n\nUsed: {used:.1f} / {limit} GB ({percent:.0f}%)\n\nYour traffic limit is almost reached.",
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>Low Balance</b>\n\nYour balance: {balance} ₽\nNotification threshold: {threshold} ₽\n\nTop up your balance to ensure automatic subscription renewal."
|
||||
}
|
||||
+1781
-1779
File diff suppressed because it is too large
Load Diff
@@ -1749,19 +1749,16 @@
|
||||
"MODEM_PRICE_WITH_DISCOUNT": "Стоимость: <s>{base_price}</s> <b>{final_price}</b> (за {months} мес)\n🎁 Скидка {discount}%: -{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT": "Стоимость: {price} (за {months} мес)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Подтверждение подключения модема</b>\n\n{price_text}\n\nПри подключении модема:\n• К подписке добавится дополнительное устройство\n• Ежемесячная плата увеличится на {monthly_price}\n\nПодтвердить подключение?",
|
||||
|
||||
"ADMIN_USER_RESTRICTIONS": "⚠️ Ограничить",
|
||||
"USER_RESTRICTION_TOPUP_BLOCKED": "🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
"USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 <b>Покупка/продление подписки ограничено</b>\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
"USER_RESTRICTION_APPEAL_BUTTON": "🆘 Обжаловать",
|
||||
|
||||
"PAUSE_DAILY_BUTTON": "⏸️ Приостановить подписку",
|
||||
"RESUME_DAILY_BUTTON": "▶️ Возобновить подписку",
|
||||
"DAILY_SWITCH_WARNING": "⚠️ <b>Внимание!</b> У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!",
|
||||
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена",
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!",
|
||||
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Подписка возобновлена!</b>\n\nВаш суточный тариф «{tariff_name}» возобновлён после пополнения баланса.\n\n💳 Списано: {amount}\n💰 Остаток: {balance}",
|
||||
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>Подписка{tariff_label} истекла</b>\n\nВаша подписка завершена. Продлите подписку, чтобы восстановить доступ к VPN.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>Подписка{tariff_label} отключена</b>\n\nВаша подписка была отключена администратором.",
|
||||
"WEBHOOK_SUB_ENABLED": "✅ <b>Подписка{tariff_label} активирована</b>\n\nВаша подписка снова активна. Приятного использования!",
|
||||
@@ -1780,5 +1777,7 @@
|
||||
"WEBHOOK_DEVICE_ADDED": "📱 <b>Новое устройство</b>\n\nК подписке{tariff_label} подключено новое устройство: <code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED": "📱 <b>Устройство удалено</b>\n\nУстройство отключено от подписки{tariff_label}: <code>{device}</code>",
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Обнаружен торрент</b>\n\nВ вашем подключении{tariff_label} обнаружен торрент-трафик. Использование торрентов может привести к ограничению подписки.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть"
|
||||
}
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть",
|
||||
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Предупреждение о трафике</b>\n\nИспользовано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш лимит трафика почти исчерпан.",
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>Низкий баланс</b>\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно."
|
||||
}
|
||||
+1528
-1526
File diff suppressed because it is too large
Load Diff
+1651
-1649
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ Usage::
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -56,6 +57,43 @@ def _prefix_logger_name(logger: Any, method_name: str, event_dict: dict[str, Any
|
||||
return event_dict
|
||||
|
||||
|
||||
def _auto_capture_exc_info(logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Auto-populate event_dict['exc_info'] so tracebacks render in files/console.
|
||||
|
||||
Without this, callers must pass ``exc_info=True`` at every ``logger.error``
|
||||
site. Instead, we try:
|
||||
1. exc_info=True → replace with sys.exc_info() (standard structlog behaviour)
|
||||
2. no exc_info but we're inside an active except block → use sys.exc_info()
|
||||
3. error/exc/exception/e/err kwarg is a BaseException with __traceback__ →
|
||||
synthesize an exc_info tuple from it
|
||||
|
||||
Result: ``logger.error('msg', error=e)`` inside any ``except`` block now
|
||||
renders the full traceback to files, console, and Telegram automatically.
|
||||
"""
|
||||
exc_info = event_dict.get('exc_info')
|
||||
if exc_info is True:
|
||||
current = sys.exc_info()
|
||||
if current[1] is not None:
|
||||
event_dict['exc_info'] = current
|
||||
return event_dict
|
||||
|
||||
if exc_info:
|
||||
return event_dict
|
||||
|
||||
current = sys.exc_info()
|
||||
if current[1] is not None:
|
||||
event_dict['exc_info'] = current
|
||||
return event_dict
|
||||
|
||||
for key in ('error', 'exc', 'exception', 'e', 'err'):
|
||||
candidate = event_dict.get(key)
|
||||
if isinstance(candidate, BaseException) and candidate.__traceback__ is not None:
|
||||
event_dict['exc_info'] = (type(candidate), candidate, candidate.__traceback__)
|
||||
return event_dict
|
||||
|
||||
return event_dict
|
||||
|
||||
|
||||
def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]:
|
||||
"""Configure structlog and return formatters + notifier.
|
||||
|
||||
@@ -82,6 +120,11 @@ def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]:
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
timestamper,
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
# Auto-capture traceback from sys.exc_info()/error-kwarg BEFORE any
|
||||
# consumer looks at event_dict. Runs for ALL log levels so files,
|
||||
# console, and Telegram all see the same traceback without requiring
|
||||
# every caller to pass exc_info=True.
|
||||
_auto_capture_exc_info,
|
||||
# TelegramNotifierProcessor MUST run while exc_info is still a raw
|
||||
# tuple so it can extract the traceback for Telegram notifications.
|
||||
# ConsoleRenderer handles exc_info formatting downstream (with Rich
|
||||
|
||||
+16
-1
@@ -129,13 +129,28 @@ class TelegramNotifierProcessor:
|
||||
if any(logger_name.startswith(prefix) for prefix in IGNORED_LOGGER_PREFIXES):
|
||||
return event_dict
|
||||
|
||||
# 4. Resolve exc_info=True to actual tuple while still in except block.
|
||||
# 4. Resolve exc_info into actual tuple while still in except block.
|
||||
# logger.exception() sets exc_info=True (bool); we need the tuple for
|
||||
# traceback extraction. sys.exc_info() works because the processor runs
|
||||
# synchronously inside the except clause.
|
||||
#
|
||||
# If exc_info is not passed at all, auto-capture traceback from:
|
||||
# (a) sys.exc_info() — works when logger.error is called inside except
|
||||
# (b) error/exc/exception kwargs if they carry __traceback__
|
||||
# This avoids having to pass exc_info=True at every logger.error site.
|
||||
exc_info = event_dict.get('exc_info')
|
||||
if exc_info is True:
|
||||
event_dict['exc_info'] = sys.exc_info()
|
||||
elif not exc_info:
|
||||
current = sys.exc_info()
|
||||
if current[1] is not None:
|
||||
event_dict['exc_info'] = current
|
||||
else:
|
||||
for key in ('error', 'exc', 'exception', 'e', 'err'):
|
||||
candidate = event_dict.get(key)
|
||||
if isinstance(candidate, BaseException) and candidate.__traceback__ is not None:
|
||||
event_dict['exc_info'] = (type(candidate), candidate, candidate.__traceback__)
|
||||
break
|
||||
|
||||
# 5. Bot not initialized yet — skip
|
||||
bot = self._bot
|
||||
|
||||
@@ -85,6 +85,9 @@ class DisplayNameRestrictionMiddleware(BaseMiddleware):
|
||||
if not user or user.is_bot:
|
||||
return await handler(event, data)
|
||||
|
||||
if not settings.DISPLAY_NAME_RESTRICTION_ENABLED:
|
||||
return await handler(event, data)
|
||||
|
||||
display_name = self._build_display_name(user)
|
||||
username = user.username or ''
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Сервис для работы с API AuraPay (aurapay.tech)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
API_BASE_URL = 'https://app.aurapay.tech'
|
||||
|
||||
|
||||
class AuraPayAPIError(Exception):
|
||||
"""Ошибка API AuraPay."""
|
||||
|
||||
def __init__(self, status_code: int, message: str):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
super().__init__(f'AuraPay API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class AuraPayService:
|
||||
"""Сервис для работы с API AuraPay."""
|
||||
|
||||
def __init__(self):
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
return settings.AURAPAY_API_KEY or ''
|
||||
|
||||
@property
|
||||
def shop_id(self) -> str:
|
||||
return settings.AURAPAY_SHOP_ID or ''
|
||||
|
||||
@property
|
||||
def secret_key(self) -> str:
|
||||
return settings.AURAPAY_SECRET_KEY or ''
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Возвращает переиспользуемую HTTP-сессию."""
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрывает HTTP-сессию."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
def _build_headers(self) -> dict[str, str]:
|
||||
"""Строит заголовки запроса с X-ApiKey и X-ShopId."""
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'X-ApiKey': self.api_key,
|
||||
'X-ShopId': self.shop_id,
|
||||
}
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
*,
|
||||
amount: float,
|
||||
order_id: str,
|
||||
comment: str = '',
|
||||
service: str | None = None,
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
callback_url: str | None = None,
|
||||
custom_fields: str | None = None,
|
||||
lifetime: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Создает инвойс через API AuraPay.
|
||||
POST /invoice/create
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'amount': amount,
|
||||
'order_id': order_id,
|
||||
}
|
||||
|
||||
if comment:
|
||||
payload['comment'] = comment
|
||||
if service:
|
||||
payload['service'] = service
|
||||
if success_url:
|
||||
payload['success_url'] = success_url
|
||||
if fail_url:
|
||||
payload['fail_url'] = fail_url
|
||||
if callback_url:
|
||||
payload['callback_url'] = callback_url
|
||||
if custom_fields:
|
||||
payload['custom_fields'] = custom_fields
|
||||
if lifetime is not None:
|
||||
payload['lifetime'] = lifetime
|
||||
|
||||
logger.info(
|
||||
'AuraPay API create_invoice',
|
||||
order_id=order_id,
|
||||
amount=amount,
|
||||
service=service,
|
||||
)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
f'{API_BASE_URL}/invoice/create',
|
||||
json=payload,
|
||||
headers=self._build_headers(),
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status == 200:
|
||||
logger.info(
|
||||
'AuraPay API invoice created',
|
||||
order_id=order_id,
|
||||
invoice_id=data.get('id'),
|
||||
status=data.get('status'),
|
||||
)
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'AuraPay create_invoice error',
|
||||
status_code=response.status,
|
||||
error_msg=error_msg,
|
||||
response_data=data,
|
||||
)
|
||||
raise AuraPayAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('AuraPay API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def get_invoice_status(
|
||||
self,
|
||||
*,
|
||||
order_id: str | None = None,
|
||||
invoice_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Получает статус инвойса.
|
||||
POST /invoice/status
|
||||
"""
|
||||
if not order_id and not invoice_id:
|
||||
raise ValueError('Either order_id or invoice_id must be provided')
|
||||
|
||||
payload: dict[str, str] = {}
|
||||
if order_id:
|
||||
payload['order_id'] = order_id
|
||||
if invoice_id:
|
||||
payload['id'] = invoice_id
|
||||
|
||||
logger.info('AuraPay get_invoice_status', order_id=order_id, invoice_id=invoice_id)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
f'{API_BASE_URL}/invoice/status',
|
||||
json=payload,
|
||||
headers=self._build_headers(),
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status == 200:
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'AuraPay get_invoice_status error',
|
||||
status_code=response.status,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
raise AuraPayAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('AuraPay API connection error', error=e)
|
||||
raise
|
||||
|
||||
def verify_webhook_signature(self, payload: dict[str, Any], received_signature: str) -> bool:
|
||||
"""Верификация подписи webhook AuraPay через HMAC-SHA256.
|
||||
|
||||
Algorithm: sort JSON keys alphabetically, concatenate all VALUES
|
||||
(converted to str) into one string, HMAC-SHA256 with secret key #2.
|
||||
Header: X-SIGNATURE
|
||||
"""
|
||||
try:
|
||||
if not received_signature:
|
||||
logger.warning('AuraPay webhook: отсутствует X-SIGNATURE')
|
||||
return False
|
||||
|
||||
# Сортируем ключи по алфавиту и конкатенируем значения
|
||||
sorted_keys = sorted(payload.keys())
|
||||
concatenated_values = ''.join(str(payload[key]) for key in sorted_keys)
|
||||
|
||||
expected = hmac.new(
|
||||
self.secret_key.encode('utf-8'),
|
||||
concatenated_values.encode('utf-8'),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(expected, received_signature)
|
||||
except Exception as e:
|
||||
logger.error('AuraPay webhook verify error', error=e)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
aurapay_service = AuraPayService()
|
||||
@@ -86,33 +86,38 @@ class BlacklistService:
|
||||
if not line or line.startswith('#'):
|
||||
continue # Пропускаем пустые строки и комментарии
|
||||
|
||||
# В формате '7021477105 #@MAMYT_PAXAL2016, перепродажа подписок'
|
||||
# В формате '7021477105 # @MAMYT_PAXAL2016, перепродажа подписок'
|
||||
# только первая часть до пробела - это Telegram ID, всё остальное комментарий
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
continue
|
||||
|
||||
try:
|
||||
telegram_id = int(parts[0]) # Первое число - это Telegram ID
|
||||
# Всё остальное - просто комментарий, не используем его для логики
|
||||
# Но можем использовать первую часть после ID как username для отображения
|
||||
username = ''
|
||||
if len(parts) > 1:
|
||||
# Берем вторую часть как username (если начинается с @)
|
||||
if parts[1].startswith('@'):
|
||||
username = parts[1]
|
||||
# 1. Разделяем строку на ID и всё остальное по символу '#'
|
||||
if '#' in line:
|
||||
id_part, content_part = line.split('#', 1)
|
||||
telegram_id = int(id_part.strip())
|
||||
content = content_part.strip()
|
||||
else:
|
||||
# Если решётки нет, пробуем просто взять первое число
|
||||
parts = line.split(maxsplit=1)
|
||||
telegram_id = int(parts[0])
|
||||
content = parts[1].strip() if len(parts) > 1 else ''
|
||||
|
||||
# По умолчанию используем "Занесен в черный список", если нет другой информации
|
||||
# 2. Обрабатываем контент: вычленяем username, если он есть в начале
|
||||
username = ''
|
||||
reason = 'Занесен в черный список'
|
||||
|
||||
# Если есть запятая в строке, можем использовать часть после нее как причину
|
||||
full_line_after_id = line[len(str(telegram_id)) :].strip()
|
||||
if ',' in full_line_after_id:
|
||||
# Извлекаем причину после запятой
|
||||
after_comma = full_line_after_id.split(',', 1)[1].strip()
|
||||
reason = after_comma
|
||||
if content:
|
||||
if content.startswith('@'):
|
||||
# Разбиваем контент только по первому пробелу
|
||||
# content_parts[0] будет юзернеймом, content_parts[1] — причиной
|
||||
content_parts = content.split(maxsplit=1)
|
||||
username = content_parts[0]
|
||||
if len(content_parts) > 1:
|
||||
reason = content_parts[1].strip()
|
||||
else:
|
||||
# Если собачки нет, значит весь контент — это причина
|
||||
reason = content
|
||||
|
||||
blacklist_data.append((telegram_id, username, reason))
|
||||
|
||||
except ValueError:
|
||||
# Если не удается преобразовать в число, это не ID
|
||||
logger.warning(
|
||||
|
||||
@@ -62,6 +62,7 @@ class BroadcastConfig:
|
||||
media: BroadcastMediaConfig | None = None
|
||||
initiator_name: str | None = None
|
||||
custom_buttons: list[dict] | None = None
|
||||
category: str = 'system' # system|news|promo
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -160,7 +161,7 @@ class BroadcastService:
|
||||
await session.commit()
|
||||
|
||||
# _fetch_recipients теперь возвращает list[int] (telegram_id), а не ORM-объекты
|
||||
recipient_ids: list[int] = await self._fetch_recipients(config.target)
|
||||
recipient_ids: list[int] = await self._fetch_recipients(config.target, config.category)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
@@ -226,8 +227,13 @@ class BroadcastService:
|
||||
logger.exception('Критическая ошибка при выполнении рассылки', broadcast_id=broadcast_id, exc=exc)
|
||||
await self._mark_failed(broadcast_id, sent_count, failed_count, blocked_count)
|
||||
|
||||
async def _fetch_recipients(self, target: str) -> list[int]:
|
||||
"""Загружает получателей и возвращает список telegram_id (скаляры, не ORM-объекты)."""
|
||||
async def _fetch_recipients(self, target: str, category: str = 'system') -> list[int]:
|
||||
"""Загружает получателей и возвращает список telegram_id (скаляры, не ORM-объекты).
|
||||
|
||||
Filters out users who disabled the given broadcast category in their
|
||||
notification preferences (news_enabled, promo_offers_enabled).
|
||||
Category 'system' is never filtered — system notifications reach everyone.
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
if target.startswith('custom_'):
|
||||
criteria = target[len('custom_') :]
|
||||
@@ -235,6 +241,17 @@ class BroadcastService:
|
||||
else:
|
||||
users_orm = await get_target_users(session, target)
|
||||
|
||||
# Filter by user notification preferences based on broadcast category
|
||||
if category == 'news':
|
||||
from app.utils.notification_prefs import is_news_enabled
|
||||
|
||||
users_orm = [u for u in users_orm if is_news_enabled(u)]
|
||||
elif category == 'promo':
|
||||
from app.utils.notification_prefs import is_promo_offers_enabled
|
||||
|
||||
users_orm = [u for u in users_orm if is_promo_offers_enabled(u)]
|
||||
# category == 'system' → no filtering, sent to everyone
|
||||
|
||||
# Извлекаем telegram_id сразу, пока сессия жива.
|
||||
# После выхода из блока ORM-объекты станут detached.
|
||||
return [u.telegram_id for u in users_orm if u.telegram_id is not None]
|
||||
|
||||
@@ -159,17 +159,13 @@ class AdvertisingCampaignService:
|
||||
device_limit = campaign.subscription_device_limit
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
try:
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
|
||||
if not squads:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(db)
|
||||
if trial_uuid:
|
||||
squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
logger.error('Не удалось подобрать сквад для кампании', campaign_id=campaign.id, error=error)
|
||||
squads = await get_effective_tariff_squad_uuids(db, campaign.subscription_squads)
|
||||
except Exception as error:
|
||||
logger.error('Не удалось подобрать сквады для кампании', campaign_id=campaign.id, error=error)
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
|
||||
if existing_subscription:
|
||||
# Multi-tariff: extend the best existing subscription
|
||||
@@ -305,17 +301,13 @@ class AdvertisingCampaignService:
|
||||
|
||||
traffic_limit = tariff.traffic_limit_gb
|
||||
device_limit = tariff.device_limit
|
||||
squads = list(tariff.allowed_squads or [])
|
||||
try:
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
|
||||
if not squads:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(db)
|
||||
if trial_uuid:
|
||||
squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
logger.error('Не удалось подобрать сквад для тарифа кампании', campaign_id=campaign.id, error=error)
|
||||
squads = await get_effective_tariff_squad_uuids(db, tariff.allowed_squads)
|
||||
except Exception as error:
|
||||
logger.error('Не удалось подобрать сквады для тарифа кампании', campaign_id=campaign.id, error=error)
|
||||
squads = list(tariff.allowed_squads or [])
|
||||
|
||||
if existing_subscription:
|
||||
# Multi-tariff: extend the existing subscription for this tariff
|
||||
|
||||
@@ -146,9 +146,22 @@ class DailySubscriptionService:
|
||||
# Недостаточно средств - приостанавливаем подписку
|
||||
await suspend_daily_subscription_insufficient_balance(db, subscription)
|
||||
|
||||
# Уведомляем пользователя
|
||||
# Уведомляем пользователя (rate-limit: 1 раз в 6 часов)
|
||||
if self._bot:
|
||||
await self._notify_insufficient_balance(user, subscription, daily_price)
|
||||
from app.utils.cache import cache
|
||||
|
||||
cache_key = f'daily_insuf_notify:{subscription.id}'
|
||||
try:
|
||||
already_notified = await cache.get(cache_key)
|
||||
except Exception:
|
||||
already_notified = None
|
||||
|
||||
if not already_notified:
|
||||
await self._notify_insufficient_balance(user, subscription, daily_price)
|
||||
try:
|
||||
await cache.set(cache_key, '1', expire=21600) # 6 hours
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
'Подписка приостановлена: недостаточно средств (баланс: требуется: )',
|
||||
@@ -264,6 +277,14 @@ class DailySubscriptionService:
|
||||
logger.warning('Не удалось синхронизировать сквады после создания', error=patch_err)
|
||||
except Exception as e:
|
||||
logger.warning('Не удалось обновить 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='update' if _has_panel_user else 'create',
|
||||
)
|
||||
|
||||
# Отправляем уведомление администраторам
|
||||
try:
|
||||
@@ -539,6 +560,14 @@ class DailySubscriptionService:
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.warning('Не удалось синхронизировать с 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='update',
|
||||
)
|
||||
|
||||
# Уведомляем пользователя
|
||||
if self._bot and subscription.user_id:
|
||||
|
||||
@@ -15,7 +15,12 @@ from app.cabinet.auth.jwt_handler import create_auto_login_token
|
||||
from app.cabinet.auth.password_utils import hash_password
|
||||
from app.config import settings
|
||||
from app.database.crud.landing import create_guest_purchase
|
||||
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, replace_subscription
|
||||
from app.database.crud.subscription import (
|
||||
create_paid_subscription,
|
||||
extend_subscription,
|
||||
get_subscription_by_user_id,
|
||||
replace_subscription,
|
||||
)
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import _get_or_create_default_promo_group
|
||||
@@ -28,6 +33,7 @@ from app.database.models import (
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
_aware,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
@@ -1039,7 +1045,24 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
||||
from app.database.crud.subscription import get_subscription_by_user_and_tariff
|
||||
|
||||
existing_for_tariff = await get_subscription_by_user_and_tariff(db, user.id, tariff.id)
|
||||
if existing_for_tariff:
|
||||
_has_time = (
|
||||
existing_for_tariff is not None
|
||||
and existing_for_tariff.end_date is not None
|
||||
and _aware(existing_for_tariff.end_date) > datetime.now(UTC)
|
||||
)
|
||||
if existing_for_tariff and _has_time:
|
||||
# Extend existing active/trial subscription instead of replacing (preserve remaining days)
|
||||
subscription = await extend_subscription(
|
||||
db,
|
||||
existing_for_tariff,
|
||||
purchase.period_days,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
connected_squads=squads,
|
||||
commit=False,
|
||||
)
|
||||
elif existing_for_tariff:
|
||||
# Expired subscription — replace with fresh dates
|
||||
subscription = await replace_subscription(
|
||||
db,
|
||||
existing_for_tariff,
|
||||
@@ -1066,7 +1089,25 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
||||
)
|
||||
else:
|
||||
existing_subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if existing_subscription is not None:
|
||||
_sub_has_time = (
|
||||
existing_subscription is not None
|
||||
and existing_subscription.end_date is not None
|
||||
and _aware(existing_subscription.end_date) > datetime.now(UTC)
|
||||
)
|
||||
if existing_subscription is not None and _sub_has_time:
|
||||
# Extend existing active subscription (preserve remaining days)
|
||||
subscription = await extend_subscription(
|
||||
db,
|
||||
existing_subscription,
|
||||
purchase.period_days,
|
||||
tariff_id=tariff.id,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
connected_squads=squads,
|
||||
commit=False,
|
||||
)
|
||||
elif existing_subscription is not None:
|
||||
# Expired subscription — replace with fresh dates
|
||||
subscription = await replace_subscription(
|
||||
db,
|
||||
existing_subscription,
|
||||
|
||||
+493
-163
@@ -245,7 +245,10 @@ class MonitoringService:
|
||||
await self._check_trial_expiring_soon(db)
|
||||
await self._check_trial_channel_subscriptions(db)
|
||||
await self._check_expired_subscription_followups(db)
|
||||
await self._check_traffic_warnings(db)
|
||||
await self._check_low_balance_alerts(db)
|
||||
await self._retry_stuck_guest_purchases(db)
|
||||
await self._cleanup_expired_refresh_tokens(db)
|
||||
await self._cleanup_inactive_users(db)
|
||||
await self._sync_with_remnawave(db)
|
||||
|
||||
@@ -517,11 +520,25 @@ class MonitoringService:
|
||||
|
||||
users_with_cards = await get_user_ids_with_active_payment_methods(db, autopay_user_ids)
|
||||
|
||||
from app.utils.notification_prefs import (
|
||||
get_subscription_expiry_days,
|
||||
is_subscription_expiry_enabled,
|
||||
)
|
||||
|
||||
for subscription in expiring_subscriptions:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
# Respect user notification preferences
|
||||
if not is_subscription_expiry_enabled(user):
|
||||
continue
|
||||
|
||||
# Check if user's preferred days threshold matches this check
|
||||
user_expiry_days = get_subscription_expiry_days(user)
|
||||
if days > user_expiry_days:
|
||||
continue
|
||||
|
||||
# Use user.id + subscription.id for key to support multiple subscriptions per user
|
||||
sub_key = f'user_{user.id}_sub_{subscription.id}_today'
|
||||
user_identifier = user.telegram_id or f'email:{user.id}'
|
||||
@@ -884,18 +901,24 @@ class MonitoringService:
|
||||
)
|
||||
|
||||
try:
|
||||
panel_uuid_restore = (
|
||||
subscription.remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
|
||||
else user.remnawave_uuid
|
||||
)
|
||||
if panel_uuid_restore:
|
||||
await self.subscription_service.enable_remnawave_user(panel_uuid_restore)
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
# create_remnawave_user calls db.commit() internally --
|
||||
# flush accumulated batch state first to preserve atomicity.
|
||||
await batch_db.commit()
|
||||
await self.subscription_service.create_remnawave_user(batch_db, subscription)
|
||||
else:
|
||||
_enable_uuid = (
|
||||
subscription.remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else user.remnawave_uuid
|
||||
)
|
||||
if _enable_uuid:
|
||||
await self.subscription_service.enable_remnawave_user(_enable_uuid)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
'Failed to update RemnaWave user',
|
||||
@@ -1179,160 +1202,229 @@ class MonitoringService:
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for subscription in autopay_subscriptions:
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск автоплатежа подписки : обновлена вебхуком недавно', subscription_id=subscription.id
|
||||
)
|
||||
continue
|
||||
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
user_identifier = user.telegram_id or f'email:{user.id}'
|
||||
|
||||
# Определяем период продления: из тарифа (минимальный) или 30 дней по умолчанию
|
||||
tariff = getattr(subscription, 'tariff', None)
|
||||
if tariff:
|
||||
autopay_period = tariff.get_shortest_period() or 30
|
||||
else:
|
||||
autopay_period = 30
|
||||
# Захватываем (sub_id, user_id) ДО цикла, пока сессия ещё свежая.
|
||||
# В цикле каждую итерацию делаем refetch subscription+user через
|
||||
# async-запрос: это единственный безопасный способ избежать
|
||||
# MissingGreenlet при sync-lazy-load, который SQLAlchemy 2.0 async
|
||||
# session не поддерживает (напр. lock_user_for_pricing c
|
||||
# populate_existing=True разгружает Subscription.user backref).
|
||||
autopay_pairs: list[tuple[int, int]] = [(s.id, s.user_id) for s in autopay_subscriptions]
|
||||
|
||||
for sub_id_local, sub_user_id_local in autopay_pairs:
|
||||
try:
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
subscription,
|
||||
autopay_period,
|
||||
user=user,
|
||||
# Refetch subscription с eager load user/tariff —
|
||||
# никаких lazy access по ходу итерации.
|
||||
refetch_result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user).options(
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(Subscription.id == sub_id_local)
|
||||
)
|
||||
renewal_cost = pricing.final_total
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка расчёта стоимости автопродления, пропускаем',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
error=str(e),
|
||||
)
|
||||
failed_count += 1
|
||||
continue
|
||||
subscription = refetch_result.scalar_one_or_none()
|
||||
if subscription is None:
|
||||
continue
|
||||
|
||||
if renewal_cost <= 0:
|
||||
logger.warning(
|
||||
'Нулевая стоимость автопродления, пропускаем',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
renewal_cost=renewal_cost,
|
||||
)
|
||||
failed_count += 1
|
||||
continue
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
# calculate_renewal_price уже включает promo_group + promo_offer скидки.
|
||||
# Не применяем promo_offer повторно — только consume-им при успешной оплате.
|
||||
charge_amount = renewal_cost
|
||||
promo_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск автоплатежа подписки : обновлена вебхуком недавно',
|
||||
subscription_id=subscription.id,
|
||||
)
|
||||
continue
|
||||
|
||||
autopay_key = f'autopay_{user.id}_{subscription.id}'
|
||||
if autopay_key in self._notified_users:
|
||||
continue
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
if user.balance_kopeks >= charge_amount:
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
charge_amount,
|
||||
'Автопродление подписки',
|
||||
consume_promo_offer=promo_discount_percent > 0,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
user_identifier = user.telegram_id or f'email:{user.id}'
|
||||
|
||||
if success:
|
||||
# extend_subscription сам обработает EXPIRED→ACTIVE переход
|
||||
# (проверяет status + end_date для определения was_expired)
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value:
|
||||
logger.info(
|
||||
'🔄 Autopay: продление EXPIRED подписки (восстановление)',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
old_end_date = subscription.end_date
|
||||
await extend_subscription(db, subscription, autopay_period)
|
||||
await self.subscription_service.update_remnawave_user(
|
||||
# Определяем период продления: из тарифа (минимальный) или 30 дней по умолчанию
|
||||
tariff = getattr(subscription, 'tariff', None)
|
||||
if tariff:
|
||||
autopay_period = tariff.get_shortest_period() or 30
|
||||
else:
|
||||
autopay_period = 30
|
||||
|
||||
try:
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason='автопродление подписки',
|
||||
autopay_period,
|
||||
user=user,
|
||||
)
|
||||
renewal_cost = pricing.final_total
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка расчёта стоимости автопродления, пропускаем',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
error=str(e),
|
||||
)
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
if renewal_cost <= 0:
|
||||
logger.warning(
|
||||
'Нулевая стоимость автопродления, пропускаем',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
renewal_cost=renewal_cost,
|
||||
)
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# calculate_renewal_price уже включает promo_group + promo_offer скидки.
|
||||
# Не применяем promo_offer повторно — только consume-им при успешной оплате.
|
||||
charge_amount = renewal_cost
|
||||
promo_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
|
||||
autopay_key = f'autopay_{user.id}_{subscription.id}'
|
||||
if autopay_key in self._notified_users:
|
||||
continue
|
||||
|
||||
if user.balance_kopeks >= charge_amount:
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
charge_amount,
|
||||
'Автопродление подписки',
|
||||
consume_promo_offer=promo_discount_percent > 0,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
|
||||
# Создаём транзакцию, чтобы автопродление было видно в статистике и карточке пользователя
|
||||
try:
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=charge_amount,
|
||||
description=f'Автопродление подписки на {autopay_period} дней',
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Не удалось создать транзакцию автопродления', user_id=user.id, exc=exc)
|
||||
transaction = None
|
||||
|
||||
# Отправляем уведомление администраторам
|
||||
try:
|
||||
from app.services.subscription_renewal_service import with_admin_notification_service
|
||||
|
||||
if transaction:
|
||||
await with_admin_notification_service(
|
||||
lambda svc: svc.send_subscription_extension_notification(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
transaction,
|
||||
autopay_period,
|
||||
old_end_date,
|
||||
new_end_date=subscription.end_date,
|
||||
balance_after=user.balance_kopeks,
|
||||
)
|
||||
if success:
|
||||
# subtract_user_balance мог оставить сессию в expired state
|
||||
# (напр. rollback внутри log_promo_offer_action при consume_promo_offer).
|
||||
# Перезагружаем subscription с eager-загрузкой user/tariff, чтобы
|
||||
# избежать MissingGreenlet на последующих обращениях к subscription.*
|
||||
refetch_result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
except Exception as exc:
|
||||
.where(Subscription.id == subscription.id)
|
||||
)
|
||||
refreshed_subscription = refetch_result.scalar_one_or_none()
|
||||
if refreshed_subscription is None:
|
||||
logger.warning(
|
||||
'Подписка пропала после списания — пропускаем шаги продления',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
processed_count += 1
|
||||
self._notified_users.add(autopay_key)
|
||||
continue
|
||||
subscription = refreshed_subscription
|
||||
|
||||
# extend_subscription сам обработает EXPIRED→ACTIVE переход
|
||||
# (проверяет status + end_date для определения was_expired)
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value:
|
||||
logger.info(
|
||||
'🔄 Autopay: продление EXPIRED подписки (восстановление)',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
old_end_date = subscription.end_date
|
||||
await extend_subscription(db, subscription, autopay_period)
|
||||
await self.subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason='автопродление подписки',
|
||||
)
|
||||
|
||||
# Создаём транзакцию, чтобы автопродление было видно в статистике и карточке пользователя
|
||||
try:
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=charge_amount,
|
||||
description=f'Автопродление подписки на {autopay_period} дней',
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Не удалось создать транзакцию автопродления', user_id=user.id, exc=exc)
|
||||
transaction = None
|
||||
|
||||
# Отправляем уведомление администраторам
|
||||
try:
|
||||
from app.services.subscription_renewal_service import with_admin_notification_service
|
||||
|
||||
if transaction:
|
||||
await with_admin_notification_service(
|
||||
lambda svc: svc.send_subscription_extension_notification(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
transaction,
|
||||
autopay_period,
|
||||
old_end_date,
|
||||
new_end_date=subscription.end_date,
|
||||
balance_after=user.balance_kopeks,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
'Не удалось отправить админ-уведомление об автопродлении', user_id=user.id, exc=exc
|
||||
)
|
||||
|
||||
# Send notification via appropriate channel
|
||||
if user.telegram_id and self.bot:
|
||||
await self._send_autopay_success_notification(
|
||||
user, charge_amount, autopay_period, subscription=subscription
|
||||
)
|
||||
elif not user.telegram_id:
|
||||
# Email-only user - use notification delivery service
|
||||
await notification_delivery_service.notify_autopay_success(
|
||||
user=user,
|
||||
amount_kopeks=charge_amount,
|
||||
new_expires_at=subscription.end_date,
|
||||
)
|
||||
|
||||
processed_count += 1
|
||||
self._notified_users.add(autopay_key)
|
||||
logger.info(
|
||||
'💳 Автопродление подписки пользователя успешно (списано , скидка %)',
|
||||
user_identifier=user_identifier,
|
||||
charge_amount=charge_amount,
|
||||
promo_discount_percent=promo_discount_percent,
|
||||
)
|
||||
else:
|
||||
failed_count += 1
|
||||
if await self._check_autopay_fail_cooldown(user.id, user_identifier):
|
||||
if user.telegram_id and self.bot:
|
||||
await self._send_autopay_failed_notification(
|
||||
user, user.balance_kopeks, charge_amount, subscription=subscription
|
||||
)
|
||||
elif not user.telegram_id:
|
||||
await notification_delivery_service.notify_autopay_failed(
|
||||
user=user,
|
||||
reason='Ошибка списания средств',
|
||||
)
|
||||
await self._set_autopay_fail_cooldown(user.id, user_identifier)
|
||||
logger.warning(
|
||||
'Не удалось отправить админ-уведомление об автопродлении', user_id=user.id, exc=exc
|
||||
'💳 Ошибка списания средств для автопродления пользователя',
|
||||
user_identifier=user_identifier,
|
||||
)
|
||||
|
||||
# Send notification via appropriate channel
|
||||
if user.telegram_id and self.bot:
|
||||
await self._send_autopay_success_notification(
|
||||
user, charge_amount, autopay_period, subscription=subscription
|
||||
)
|
||||
elif not user.telegram_id:
|
||||
# Email-only user - use notification delivery service
|
||||
await notification_delivery_service.notify_autopay_success(
|
||||
user=user,
|
||||
amount_kopeks=charge_amount,
|
||||
new_expires_at=subscription.end_date,
|
||||
)
|
||||
|
||||
processed_count += 1
|
||||
self._notified_users.add(autopay_key)
|
||||
logger.info(
|
||||
'💳 Автопродление подписки пользователя успешно (списано , скидка %)',
|
||||
user_identifier=user_identifier,
|
||||
charge_amount=charge_amount,
|
||||
promo_discount_percent=promo_discount_percent,
|
||||
)
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
if await self._check_autopay_fail_cooldown(user.id, user_identifier):
|
||||
if user.telegram_id and self.bot:
|
||||
await self._send_autopay_failed_notification(
|
||||
@@ -1341,30 +1433,35 @@ class MonitoringService:
|
||||
elif not user.telegram_id:
|
||||
await notification_delivery_service.notify_autopay_failed(
|
||||
user=user,
|
||||
reason='Ошибка списания средств',
|
||||
reason='Недостаточно средств на балансе',
|
||||
)
|
||||
await self._set_autopay_fail_cooldown(user.id, user_identifier)
|
||||
|
||||
logger.warning(
|
||||
'💳 Ошибка списания средств для автопродления пользователя', user_identifier=user_identifier
|
||||
'💳 Недостаточно средств для автопродления у пользователя',
|
||||
user_identifier=user_identifier,
|
||||
)
|
||||
else:
|
||||
except Exception as sub_error:
|
||||
failed_count += 1
|
||||
|
||||
if await self._check_autopay_fail_cooldown(user.id, user_identifier):
|
||||
if user.telegram_id and self.bot:
|
||||
await self._send_autopay_failed_notification(
|
||||
user, user.balance_kopeks, charge_amount, subscription=subscription
|
||||
)
|
||||
elif not user.telegram_id:
|
||||
await notification_delivery_service.notify_autopay_failed(
|
||||
user=user,
|
||||
reason='Недостаточно средств на балансе',
|
||||
)
|
||||
await self._set_autopay_fail_cooldown(user.id, user_identifier)
|
||||
|
||||
logger.warning(
|
||||
'💳 Недостаточно средств для автопродления у пользователя', user_identifier=user_identifier
|
||||
# Используем локально захваченные id — subscription-объект
|
||||
# может быть expired после чужого rollback'а.
|
||||
logger.error(
|
||||
'Ошибка автопродления отдельной подписки',
|
||||
subscription_id=sub_id_local,
|
||||
user_id=sub_user_id_local,
|
||||
error=sub_error,
|
||||
exc_info=True,
|
||||
)
|
||||
# Сессия могла остаться с aborted-транзакцией — откатываем,
|
||||
# чтобы следующая итерация начала refetch на чистой сессии.
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error:
|
||||
logger.warning(
|
||||
'Не удалось сделать rollback сессии после ошибки автопродления',
|
||||
rollback_error=rollback_error,
|
||||
)
|
||||
continue
|
||||
|
||||
if processed_count > 0 or failed_count > 0:
|
||||
await self._log_monitoring_event(
|
||||
@@ -1375,7 +1472,7 @@ class MonitoringService:
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обработки автоплатежей', error=e)
|
||||
logger.error('Ошибка обработки автоплатежей', error=e, exc_info=True)
|
||||
|
||||
async def _send_subscription_expired_notification(
|
||||
self, user: User, subscription: Subscription, *, tariff_name: str | None = None
|
||||
@@ -1960,6 +2057,239 @@ class MonitoringService:
|
||||
except Exception:
|
||||
logger.error('Error retrying stuck PENDING_ACTIVATION guest purchases', exc_info=True)
|
||||
|
||||
async def _check_traffic_warnings(self, db: AsyncSession):
|
||||
"""Check subscriptions approaching traffic limit and notify users."""
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import Subscription
|
||||
from app.utils.notification_prefs import get_traffic_warning_percent, is_traffic_warning_enabled
|
||||
|
||||
# Get active subscriptions with traffic limits (not unlimited)
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
Subscription.status.in_(['active', 'trial']),
|
||||
Subscription.traffic_limit_gb > 0,
|
||||
)
|
||||
)
|
||||
subscriptions = result.scalars().all()
|
||||
|
||||
sent_count = 0
|
||||
for subscription in subscriptions:
|
||||
user = subscription.user
|
||||
if not user or not user.telegram_id:
|
||||
continue
|
||||
|
||||
if not is_traffic_warning_enabled(user):
|
||||
continue
|
||||
|
||||
traffic_limit = subscription.traffic_limit_gb or 0
|
||||
traffic_used = subscription.traffic_used_gb or 0.0
|
||||
|
||||
if traffic_limit <= 0:
|
||||
continue
|
||||
|
||||
current_percent = (traffic_used / traffic_limit) * 100
|
||||
user_threshold = get_traffic_warning_percent(user)
|
||||
|
||||
if current_percent < user_threshold:
|
||||
continue
|
||||
|
||||
# Rate-limit: 1 notification per subscription per 24 hours
|
||||
cache_key_str = f'traffic_warn:{subscription.id}'
|
||||
try:
|
||||
already_sent = await cache.get(cache_key_str)
|
||||
if already_sent:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
language = getattr(user, 'language', 'ru') or 'ru'
|
||||
texts = get_texts(language)
|
||||
message = texts.get(
|
||||
'TRAFFIC_WARNING_ALERT',
|
||||
'⚠️ <b>Предупреждение о трафике</b>\n\n'
|
||||
'Использовано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\n'
|
||||
'Ваш лимит трафика почти исчерпан.',
|
||||
)
|
||||
message = message.format(
|
||||
used=traffic_used,
|
||||
limit=traffic_limit,
|
||||
percent=current_percent,
|
||||
)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
try:
|
||||
await cache.set(cache_key_str, '1', expire=86400)
|
||||
except Exception:
|
||||
pass
|
||||
sent_count += 1
|
||||
except Exception as send_error:
|
||||
logger.debug(
|
||||
'Failed to send traffic warning',
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
error=send_error,
|
||||
)
|
||||
|
||||
if sent_count > 0:
|
||||
logger.info('Traffic warnings sent', sent_count=sent_count)
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Error checking traffic warnings', error=error)
|
||||
|
||||
async def _check_low_balance_alerts(self, db: AsyncSession):
|
||||
"""Check users with autopay enabled who have low balance and notify them.
|
||||
|
||||
Guards:
|
||||
- Disabled by default; users opt-in via cabinet notification settings
|
||||
- Only alerts when subscription expires within LOW_BALANCE_ALERT_EXPIRY_DAYS (default 3)
|
||||
- Quiet hours: skips sending between 22:00 and 09:00 server time
|
||||
- Rate-limited: max 1 alert per 24 hours per user
|
||||
"""
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
try:
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import Subscription, User
|
||||
from app.utils.notification_prefs import get_balance_low_threshold, is_balance_low_enabled
|
||||
|
||||
# Quiet hours: don't disturb users at night (22:00-09:00 UTC)
|
||||
current_hour = datetime.now(UTC).hour
|
||||
if current_hour >= 22 or current_hour < 9:
|
||||
return
|
||||
|
||||
# Only alert for subscriptions expiring soon (default 3 days)
|
||||
expiry_days = getattr(settings, 'LOW_BALANCE_ALERT_EXPIRY_DAYS', 3)
|
||||
expiry_threshold = datetime.now(UTC) + timedelta(days=expiry_days)
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.join(Subscription, Subscription.user_id == User.id)
|
||||
.where(
|
||||
Subscription.status.in_(['active', 'trial']),
|
||||
Subscription.autopay_enabled.is_(True),
|
||||
Subscription.end_date.isnot(None),
|
||||
Subscription.end_date <= expiry_threshold,
|
||||
User.telegram_id.isnot(None),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
users = result.scalars().all()
|
||||
|
||||
sent_count = 0
|
||||
for user in users:
|
||||
if not is_balance_low_enabled(user):
|
||||
continue
|
||||
|
||||
threshold = get_balance_low_threshold(user)
|
||||
balance = int(getattr(user, 'balance_kopeks', 0) or 0)
|
||||
|
||||
if balance >= threshold:
|
||||
continue
|
||||
|
||||
# Rate-limit via Redis: max 1 notification per 24 hours per user
|
||||
cache_key_str = f'low_balance_alert:{user.id}'
|
||||
try:
|
||||
already_sent = await cache.get(cache_key_str)
|
||||
if already_sent:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
language = getattr(user, 'language', 'ru') or 'ru'
|
||||
texts = get_texts(language)
|
||||
threshold_rub = threshold / 100
|
||||
balance_rub = balance / 100
|
||||
message = texts.get(
|
||||
'LOW_BALANCE_ALERT',
|
||||
'⚠️ <b>Низкий баланс</b>\n\n'
|
||||
'Ваш баланс: {balance} ₽\n'
|
||||
'Порог уведомления: {threshold} ₽\n\n'
|
||||
'Пополните баланс, чтобы автопродление подписки прошло успешно.',
|
||||
)
|
||||
message = message.format(
|
||||
balance=f'{balance_rub:.0f}',
|
||||
threshold=f'{threshold_rub:.0f}',
|
||||
)
|
||||
|
||||
# Build inline keyboard with cabinet top-up button
|
||||
keyboard = None
|
||||
miniapp_url = settings.get_main_menu_miniapp_url()
|
||||
if miniapp_url:
|
||||
topup_label = texts.get('LOW_BALANCE_TOPUP_BUTTON', '💳 Пополнить баланс')
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=topup_label,
|
||||
web_app=WebAppInfo(url=miniapp_url),
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
# Mark as sent for 24 hours
|
||||
try:
|
||||
await cache.set(cache_key_str, '1', expire=86400)
|
||||
except Exception:
|
||||
pass
|
||||
sent_count += 1
|
||||
except Exception as send_error:
|
||||
logger.debug('Failed to send low balance alert', user_id=user.id, error=send_error)
|
||||
|
||||
if sent_count > 0:
|
||||
logger.info('Low balance alerts sent', sent_count=sent_count)
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Error checking low balance alerts', error=error)
|
||||
|
||||
async def _cleanup_expired_refresh_tokens(self, db: AsyncSession):
|
||||
"""Delete expired and revoked refresh tokens to prevent table bloat."""
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.database.models import CabinetRefreshToken
|
||||
|
||||
now = datetime.now(UTC)
|
||||
# Delete tokens that are either expired or revoked more than 24h ago
|
||||
stmt = delete(CabinetRefreshToken).where(
|
||||
(CabinetRefreshToken.expires_at < now) | (CabinetRefreshToken.revoked_at < now - timedelta(hours=24))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
deleted = result.rowcount
|
||||
if deleted > 0:
|
||||
await db.commit()
|
||||
logger.info('Cleaned up expired/revoked refresh tokens', deleted_count=deleted)
|
||||
except Exception as error:
|
||||
logger.error('Error cleaning up refresh tokens', error=error)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _cleanup_inactive_users(self, db: AsyncSession):
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
@@ -156,11 +156,20 @@ class NalogoQueueService:
|
||||
payment_id = receipt_data.get('payment_id', 'unknown')
|
||||
amount = receipt_data.get('amount', 0)
|
||||
|
||||
# Логируем количество попыток (чек никогда не удаляется из очереди)
|
||||
if attempts >= 10:
|
||||
logger.warning(
|
||||
'Чек уже много попыток, продолжаем пытаться...', payment_id=payment_id, attempts=attempts
|
||||
# Проверяем лимит попыток
|
||||
if attempts >= self._max_attempts:
|
||||
logger.error(
|
||||
'Чек превысил максимальное количество попыток, удалён из очереди',
|
||||
payment_id=payment_id,
|
||||
attempts=attempts,
|
||||
max_attempts=self._max_attempts,
|
||||
)
|
||||
# Удаляем метку "в очереди" — чек больше не будет обрабатываться
|
||||
if payment_id and payment_id != 'unknown':
|
||||
queued_key = f'nalogo:queued:{payment_id}'
|
||||
await cache.delete(queued_key)
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
# Пытаемся отправить чек
|
||||
try:
|
||||
|
||||
@@ -79,6 +79,7 @@ class NotificationType(Enum):
|
||||
WEBHOOK_USER_NOT_CONNECTED = 'webhook_user_not_connected'
|
||||
WEBHOOK_DEVICE_ADDED = 'webhook_device_added'
|
||||
WEBHOOK_DEVICE_DELETED = 'webhook_device_deleted'
|
||||
WEBHOOK_TORRENT_DETECTED = 'webhook_torrent_detected'
|
||||
|
||||
# Other
|
||||
BROADCAST = 'broadcast'
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Сервис для работы с API Overpay (pay.overpay.io)."""
|
||||
|
||||
import ssl
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
BestAvailableEncryption,
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
pkcs12,
|
||||
)
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class OverpayAPIError(Exception):
|
||||
"""Ошибка API Overpay."""
|
||||
|
||||
def __init__(self, status_code: int, message: str):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
super().__init__(f'Overpay API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class OverpayService:
|
||||
"""Сервис для работы с API Overpay.
|
||||
|
||||
Overpay использует HTTP Basic Auth + mTLS (P12 сертификат).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._ssl_context: ssl.SSLContext | None = None
|
||||
self._temp_cert_file: str | None = None
|
||||
self._temp_key_file: str | None = None
|
||||
|
||||
@property
|
||||
def api_url(self) -> str:
|
||||
return (settings.OVERPAY_API_URL or 'https://api.overpay.io').rstrip('/')
|
||||
|
||||
@property
|
||||
def username(self) -> str:
|
||||
return settings.OVERPAY_USERNAME or ''
|
||||
|
||||
@property
|
||||
def password(self) -> str:
|
||||
return settings.OVERPAY_PASSWORD or ''
|
||||
|
||||
@property
|
||||
def project_id(self) -> str:
|
||||
return settings.OVERPAY_PROJECT_ID or ''
|
||||
|
||||
def _build_ssl_context(self) -> ssl.SSLContext | None:
|
||||
"""Создает SSL контекст с P12 сертификатом для mTLS."""
|
||||
p12_path = settings.OVERPAY_P12_PATH
|
||||
if not p12_path:
|
||||
return None
|
||||
|
||||
if self._ssl_context is not None:
|
||||
return self._ssl_context
|
||||
|
||||
try:
|
||||
passphrase = settings.OVERPAY_P12_PASSPHRASE
|
||||
passphrase_bytes = passphrase.encode('utf-8') if passphrase else None
|
||||
|
||||
with open(p12_path, 'rb') as f:
|
||||
p12_data = f.read()
|
||||
|
||||
private_key, certificate, additional_certs = pkcs12.load_key_and_certificates(p12_data, passphrase_bytes)
|
||||
|
||||
# Write PEM files to temp files for ssl.SSLContext
|
||||
cert_pem = certificate.public_bytes(Encoding.PEM)
|
||||
if additional_certs:
|
||||
for cert in additional_certs:
|
||||
cert_pem += cert.public_bytes(Encoding.PEM)
|
||||
|
||||
if passphrase_bytes:
|
||||
key_pem = private_key.private_bytes(
|
||||
Encoding.PEM,
|
||||
PrivateFormat.TraditionalOpenSSL,
|
||||
BestAvailableEncryption(passphrase_bytes),
|
||||
)
|
||||
else:
|
||||
key_pem = private_key.private_bytes(
|
||||
Encoding.PEM,
|
||||
PrivateFormat.TraditionalOpenSSL,
|
||||
NoEncryption(),
|
||||
)
|
||||
|
||||
# Write to temp files
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.pem') as cert_file:
|
||||
cert_file.write(cert_pem)
|
||||
cert_file.flush()
|
||||
self._temp_cert_file = cert_file.name
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.pem') as key_file:
|
||||
key_file.write(key_pem)
|
||||
key_file.flush()
|
||||
self._temp_key_file = key_file.name
|
||||
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.load_cert_chain(
|
||||
certfile=self._temp_cert_file,
|
||||
keyfile=self._temp_key_file,
|
||||
password=passphrase,
|
||||
)
|
||||
ctx.load_default_certs()
|
||||
self._ssl_context = ctx
|
||||
logger.info('Overpay: SSL контекст с P12 сертификатом создан')
|
||||
return ctx
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Overpay: ошибка загрузки P12 сертификата', error=e)
|
||||
return None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Возвращает переиспользуемый HTTP-клиент с mTLS."""
|
||||
if self._client is not None and not self._client.is_closed:
|
||||
return self._client
|
||||
|
||||
ssl_context = self._build_ssl_context()
|
||||
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(30.0),
|
||||
auth=httpx.BasicAuth(self.username, self.password),
|
||||
verify=ssl_context if ssl_context else True,
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрывает HTTP-клиент."""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
# Clean up temp files
|
||||
from pathlib import Path
|
||||
|
||||
for path in (self._temp_cert_file, self._temp_key_file):
|
||||
if path:
|
||||
try:
|
||||
Path(path).unlink()
|
||||
except OSError:
|
||||
pass
|
||||
self._temp_cert_file = None
|
||||
self._temp_key_file = None
|
||||
self._ssl_context = None
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
amount: str,
|
||||
currency: str = 'RUB',
|
||||
lifetime_minutes: int = 1440,
|
||||
merchant_transaction_id: str,
|
||||
description: str = '',
|
||||
return_url: str | None = None,
|
||||
payment_methods: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Создает платеж через API Overpay.
|
||||
POST {API_URL}/orders/
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'amount': amount,
|
||||
'currency': currency,
|
||||
'livetimeMinutes': lifetime_minutes,
|
||||
'projectId': self.project_id,
|
||||
'merchantTransactionId': merchant_transaction_id,
|
||||
}
|
||||
|
||||
if description:
|
||||
payload['description'] = description
|
||||
if return_url:
|
||||
payload['returnUrl'] = return_url
|
||||
if payment_methods:
|
||||
payload['paymentMethods'] = payment_methods
|
||||
|
||||
logger.info(
|
||||
'Overpay API create_payment',
|
||||
merchant_transaction_id=merchant_transaction_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.post(
|
||||
f'{self.api_url}/orders/',
|
||||
json=payload,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200 or response.status_code == 201:
|
||||
logger.info(
|
||||
'Overpay API payment created',
|
||||
merchant_transaction_id=merchant_transaction_id,
|
||||
overpay_id=data.get('id'),
|
||||
result_url=data.get('resultUrl'),
|
||||
)
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'Overpay create_payment error',
|
||||
status_code=response.status_code,
|
||||
error_msg=error_msg,
|
||||
response_data=data,
|
||||
)
|
||||
raise OverpayAPIError(response.status_code, error_msg)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.exception('Overpay API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def get_payment(self, order_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Получает информацию о платеже по ID.
|
||||
GET {API_URL}/orders/{id}
|
||||
"""
|
||||
logger.info('Overpay get_payment', order_id=order_id)
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get(
|
||||
f'{self.api_url}/orders/{order_id}',
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'Overpay get_payment error',
|
||||
status_code=response.status_code,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
raise OverpayAPIError(response.status_code, error_msg)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.exception('Overpay API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def refund_payment(self, order_id: str, amount: str) -> dict[str, Any]:
|
||||
"""
|
||||
Возврат платежа.
|
||||
PUT {API_URL}/orders/{id}/refund
|
||||
"""
|
||||
logger.info('Overpay refund_payment', order_id=order_id, amount=amount)
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.put(
|
||||
f'{self.api_url}/orders/{order_id}/refund',
|
||||
json={'amount': amount},
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info('Overpay refund successful', order_id=order_id, amount=amount)
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'Overpay refund error',
|
||||
status_code=response.status_code,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
raise OverpayAPIError(response.status_code, error_msg)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.exception('Overpay API connection error', error=e)
|
||||
raise
|
||||
|
||||
|
||||
# Singleton instance
|
||||
overpay_service = OverpayService()
|
||||
@@ -1121,10 +1121,6 @@ class PartnerStatsService:
|
||||
),
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
func.abs(Transaction.amount_kopeks),
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
@@ -1137,12 +1133,8 @@ class PartnerStatsService:
|
||||
and_(
|
||||
Transaction.user_id == User.id,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.DEPOSIT.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
),
|
||||
)
|
||||
.where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
|
||||
@@ -1159,10 +1151,6 @@ class PartnerStatsService:
|
||||
),
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
func.abs(Transaction.amount_kopeks),
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
оставался компактным и импортировал только нужные компоненты.
|
||||
"""
|
||||
|
||||
from .aurapay import AuraPayPaymentMixin
|
||||
from .cloudpayments import CloudPaymentsPaymentMixin
|
||||
from .common import PaymentCommonMixin
|
||||
from .cryptobot import CryptoBotPaymentMixin
|
||||
@@ -11,9 +12,12 @@ from .freekassa import FreekassaPaymentMixin
|
||||
from .heleket import HeleketPaymentMixin
|
||||
from .kassa_ai import KassaAiPaymentMixin
|
||||
from .mulenpay import MulenPayPaymentMixin
|
||||
from .overpay import OverpayPaymentMixin
|
||||
from .pal24 import Pal24PaymentMixin
|
||||
from .paypear import PayPearPaymentMixin
|
||||
from .platega import PlategaPaymentMixin
|
||||
from .riopay import RioPayPaymentMixin
|
||||
from .rollypay import RollyPayPaymentMixin
|
||||
from .severpay import SeverPayPaymentMixin
|
||||
from .stars import TelegramStarsMixin
|
||||
from .tribute import TributePaymentMixin
|
||||
@@ -22,16 +26,20 @@ from .yookassa import YooKassaPaymentMixin
|
||||
|
||||
|
||||
__all__ = [
|
||||
'AuraPayPaymentMixin',
|
||||
'CloudPaymentsPaymentMixin',
|
||||
'CryptoBotPaymentMixin',
|
||||
'FreekassaPaymentMixin',
|
||||
'HeleketPaymentMixin',
|
||||
'KassaAiPaymentMixin',
|
||||
'MulenPayPaymentMixin',
|
||||
'OverpayPaymentMixin',
|
||||
'Pal24PaymentMixin',
|
||||
'PayPearPaymentMixin',
|
||||
'PaymentCommonMixin',
|
||||
'PlategaPaymentMixin',
|
||||
'RioPayPaymentMixin',
|
||||
'RollyPayPaymentMixin',
|
||||
'SeverPayPaymentMixin',
|
||||
'TelegramStarsMixin',
|
||||
'TributePaymentMixin',
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
"""Mixin для интеграции с AuraPay (aurapay.tech)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.aurapay_service import aurapay_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов AuraPay -> internal
|
||||
AURAPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'PENDING': ('pending', False),
|
||||
'PAID': ('success', True),
|
||||
'EXPIRED': ('expired', False),
|
||||
}
|
||||
|
||||
|
||||
class AuraPayPaymentMixin:
|
||||
"""Mixin для работы с платежами AuraPay."""
|
||||
|
||||
async def create_aurapay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
payment_method_type: str | None = None,
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж AuraPay.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_aurapay_enabled():
|
||||
logger.error('AuraPay не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.AURAPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'AuraPay: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
AURAPAY_MIN_AMOUNT_KOPEKS=settings.AURAPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.AURAPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'AuraPay: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
AURAPAY_MAX_AMOUNT_KOPEKS=settings.AURAPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'ap{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.AURAPAY_CURRENCY
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
try:
|
||||
# Формируем webhook URL
|
||||
webhook_url = None
|
||||
if settings.WEBHOOK_URL:
|
||||
webhook_url = f'{settings.WEBHOOK_URL.rstrip("/")}{settings.AURAPAY_WEBHOOK_PATH}'
|
||||
|
||||
lifetime = settings.AURAPAY_PAYMENT_LIFETIME_MINUTES
|
||||
|
||||
# Используем API для создания инвойса
|
||||
result = await aurapay_service.create_invoice(
|
||||
amount=amount_rubles,
|
||||
order_id=order_id,
|
||||
comment=description,
|
||||
service=payment_method_type,
|
||||
success_url=return_url or settings.AURAPAY_RETURN_URL,
|
||||
fail_url=return_url or settings.AURAPAY_RETURN_URL,
|
||||
callback_url=webhook_url,
|
||||
custom_fields=f'user_id={user_id}' if user_id else None,
|
||||
lifetime=lifetime,
|
||||
)
|
||||
|
||||
payment_data = result.get('payment_data', {})
|
||||
payment_url = payment_data.get('url') if isinstance(payment_data, dict) else None
|
||||
aurapay_invoice_id = result.get('id')
|
||||
|
||||
if not payment_url:
|
||||
logger.error('AuraPay API не вернул URL платежа', result=result)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'AuraPay API: создан инвойс',
|
||||
order_id=order_id,
|
||||
aurapay_invoice_id=aurapay_invoice_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
# Срок действия из expires_at ответа или lifetime минут по умолчанию
|
||||
expires_at_str = result.get('expires_at')
|
||||
if expires_at_str:
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(expires_at_str)
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
except (ValueError, TypeError):
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
|
||||
else:
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
|
||||
|
||||
# Сохраняем в БД
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
local_payment = await aurapay_crud.create_aurapay_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method_type,
|
||||
aurapay_invoice_id=aurapay_invoice_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'AuraPay: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'aurapay_invoice_id': aurapay_invoice_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('AuraPay: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
async def process_aurapay_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает webhook от AuraPay.
|
||||
|
||||
Подпись проверяется в webserver/payments.py до вызова этого метода.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело webhook (signature проверена в webserver)
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
aurapay_invoice_id = payload.get('id')
|
||||
order_id = payload.get('order_id')
|
||||
aurapay_status = payload.get('status')
|
||||
|
||||
if not aurapay_invoice_id or not aurapay_status:
|
||||
logger.warning('AuraPay webhook: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Определяем is_paid по статусу
|
||||
is_confirmed = aurapay_status == 'PAID'
|
||||
|
||||
# Ищем платеж по order_id (наш) или aurapay_invoice_id
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
payment = None
|
||||
if order_id:
|
||||
payment = await aurapay_crud.get_aurapay_payment_by_order_id(db, str(order_id))
|
||||
if not payment and aurapay_invoice_id:
|
||||
payment = await aurapay_crud.get_aurapay_payment_by_invoice_id(db, aurapay_invoice_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'AuraPay webhook: платеж не найден',
|
||||
order_id=order_id,
|
||||
aurapay_invoice_id=aurapay_invoice_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await aurapay_crud.get_aurapay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('AuraPay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Проверка дублирования (re-check from locked row)
|
||||
if payment.is_paid:
|
||||
logger.info('AuraPay webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = AURAPAY_STATUS_MAP.get(aurapay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
# Если статус PAID, принудительно считаем оплаченным
|
||||
if is_confirmed:
|
||||
is_paid = True
|
||||
internal_status = 'success'
|
||||
|
||||
callback_payload = {
|
||||
'aurapay_invoice_id': aurapay_invoice_id,
|
||||
'order_id': order_id,
|
||||
'status': aurapay_status,
|
||||
'amount': payload.get('amount'),
|
||||
'service': payload.get('service'),
|
||||
'payer_details': payload.get('payer_details'),
|
||||
}
|
||||
|
||||
# Проверка суммы ДО обновления статуса
|
||||
# AuraPay отправляет amount как строку "1250.00"
|
||||
if is_paid:
|
||||
amount_value = payload.get('amount')
|
||||
if amount_value is not None:
|
||||
received_kopeks = round(float(amount_value) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'AuraPay amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await aurapay_crud.update_aurapay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
aurapay_invoice_id=aurapay_invoice_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
# Финализируем платеж если оплачен — без промежуточного commit
|
||||
if is_paid:
|
||||
# Inline field assignments to keep FOR UPDATE lock intact
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.aurapay_invoice_id = aurapay_invoice_id or payment.aurapay_invoice_id
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_aurapay_payment(
|
||||
db, payment, aurapay_invoice_id=aurapay_invoice_id, trigger='webhook'
|
||||
)
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
payment = await aurapay_crud.update_aurapay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
aurapay_invoice_id=aurapay_invoice_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('AuraPay webhook: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_aurapay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
aurapay_invoice_id: str | None,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock must be acquired by the caller before invoking this method.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'AuraPay платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
# Read fresh metadata AFTER lock to avoid stale data
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
# --- Guest purchase flow ---
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=str(aurapay_invoice_id) if aurapay_invoice_id else payment.order_id,
|
||||
provider_name='aurapay',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
# Ensure paid fields are set (idempotent — caller may have already set them)
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для AuraPay', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
# Загружаем промогруппы в асинхронном контексте
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = str(aurapay_invoice_id) if aurapay_invoice_id else payment.order_id
|
||||
|
||||
# Проверяем дупликат транзакции
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.AURAPAY,
|
||||
)
|
||||
|
||||
display_name = settings.get_aurapay_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.AURAPAY,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await aurapay_crud.link_aurapay_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('AuraPay платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.AURAPAY,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения AuraPay', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления AuraPay', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'\u2705 <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю AuraPay', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан AuraPay платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_aurapay_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Проверяет статус платежа через API."""
|
||||
try:
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
payment = await aurapay_crud.get_aurapay_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
logger.warning('AuraPay payment not found', order_id=order_id)
|
||||
return None
|
||||
|
||||
if payment.is_paid:
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
# Проверяем через API по aurapay_invoice_id или order_id
|
||||
try:
|
||||
order_data = await aurapay_service.get_invoice_status(
|
||||
order_id=payment.order_id,
|
||||
invoice_id=payment.aurapay_invoice_id,
|
||||
)
|
||||
aurapay_status = order_data.get('status')
|
||||
|
||||
if aurapay_status:
|
||||
status_info = AURAPAY_STATUS_MAP.get(aurapay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
if is_paid:
|
||||
# Проверка суммы — AuraPay возвращает amount как число
|
||||
api_amount = order_data.get('amount')
|
||||
if api_amount is not None:
|
||||
received_kopeks = round(float(api_amount) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'AuraPay amount mismatch (API check)',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await aurapay_crud.update_aurapay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
aurapay_invoice_id=payment.aurapay_invoice_id,
|
||||
callback_payload={
|
||||
'check_source': 'api',
|
||||
'aurapay_order_data': order_data,
|
||||
},
|
||||
)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'amount_mismatch',
|
||||
'is_paid': False,
|
||||
}
|
||||
|
||||
# Acquire FOR UPDATE lock before finalization
|
||||
locked = await aurapay_crud.get_aurapay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('AuraPay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return None
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('AuraPay платеж уже обработан (api_check)', order_id=payment.order_id)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
logger.info('AuraPay payment confirmed via API', order_id=payment.order_id)
|
||||
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.callback_payload = {
|
||||
'check_source': 'api',
|
||||
'aurapay_order_data': order_data,
|
||||
}
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await self._finalize_aurapay_payment(
|
||||
db,
|
||||
payment,
|
||||
aurapay_invoice_id=payment.aurapay_invoice_id,
|
||||
trigger='api_check',
|
||||
)
|
||||
elif internal_status != payment.status:
|
||||
# Обновляем статус если изменился
|
||||
payment = await aurapay_crud.update_aurapay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error checking AuraPay payment status via API', error=e)
|
||||
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': payment.status or 'pending',
|
||||
'is_paid': payment.is_paid,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('AuraPay: ошибка проверки статуса', error=e)
|
||||
return None
|
||||
@@ -58,6 +58,7 @@ class HeleketPaymentMixin:
|
||||
'currency': 'RUB',
|
||||
'order_id': order_id,
|
||||
'lifetime': settings.get_heleket_lifetime(),
|
||||
'from_referral_code': 'wZ7QrW',
|
||||
}
|
||||
|
||||
to_currency = (settings.HELEKET_DEFAULT_CURRENCY or '').strip()
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
"""Mixin для интеграции с Overpay (pay.overpay.io)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.overpay_service import overpay_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов Overpay -> internal
|
||||
OVERPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'charged': ('success', True),
|
||||
'authorized': ('authorized', False),
|
||||
'preflight': ('pending', False),
|
||||
'new': ('pending', False),
|
||||
'processing': ('processing', False),
|
||||
'prepared': ('processing', False),
|
||||
'rejected': ('rejected', False),
|
||||
'declined': ('declined', False),
|
||||
'reversed': ('reversed', False),
|
||||
'refunded': ('refunded', False),
|
||||
'chargeback': ('chargeback', False),
|
||||
'error': ('error', False),
|
||||
}
|
||||
|
||||
|
||||
class OverpayPaymentMixin:
|
||||
"""Mixin для работы с платежами Overpay."""
|
||||
|
||||
async def create_overpay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж Overpay.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_overpay_enabled():
|
||||
logger.error('Overpay не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.OVERPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Overpay: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
OVERPAY_MIN_AMOUNT_KOPEKS=settings.OVERPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.OVERPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Overpay: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
OVERPAY_MAX_AMOUNT_KOPEKS=settings.OVERPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'op{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
amount_value = f'{amount_rubles:.2f}'
|
||||
currency = settings.OVERPAY_CURRENCY
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
# Методы оплаты из настроек
|
||||
payment_methods_str = settings.OVERPAY_PAYMENT_METHODS
|
||||
payment_methods = (
|
||||
[m.strip() for m in payment_methods_str.split(',') if m.strip()] if payment_methods_str else None
|
||||
)
|
||||
|
||||
try:
|
||||
# Используем API для создания платежа
|
||||
result = await overpay_service.create_payment(
|
||||
amount=amount_value,
|
||||
currency=currency,
|
||||
lifetime_minutes=settings.OVERPAY_LIFETIME_MINUTES,
|
||||
merchant_transaction_id=order_id,
|
||||
description=description,
|
||||
return_url=return_url or settings.OVERPAY_RETURN_URL,
|
||||
payment_methods=payment_methods,
|
||||
)
|
||||
|
||||
payment_url = result.get('resultUrl')
|
||||
overpay_payment_id = str(result.get('id', '')) if result.get('id') else None
|
||||
|
||||
if not payment_url:
|
||||
logger.error('Overpay API не вернул URL платежа', result=result)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'Overpay API: создан платеж',
|
||||
order_id=order_id,
|
||||
overpay_payment_id=overpay_payment_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
# Срок действия
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=settings.OVERPAY_LIFETIME_MINUTES)
|
||||
|
||||
# Сохраняем в БД
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
local_payment = await overpay_crud.create_overpay_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
overpay_payment_id=overpay_payment_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Overpay: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'overpay_payment_id': overpay_payment_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Overpay: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
async def process_overpay_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает webhook от Overpay.
|
||||
|
||||
mTLS обеспечивает аутентификацию; дополнительно проверяем наличие платежа в БД.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело webhook
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
overpay_payment_id = str(payload.get('id', '')) if payload.get('id') else None
|
||||
merchant_transaction_id = payload.get('merchantTransactionId')
|
||||
overpay_status = payload.get('status')
|
||||
|
||||
if not overpay_payment_id or not overpay_status:
|
||||
logger.warning('Overpay webhook: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Ищем платеж по order_id (наш merchantTransactionId) или overpay_payment_id
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
payment = None
|
||||
if merchant_transaction_id:
|
||||
payment = await overpay_crud.get_overpay_payment_by_order_id(db, merchant_transaction_id)
|
||||
if not payment and overpay_payment_id:
|
||||
payment = await overpay_crud.get_overpay_payment_by_overpay_id(db, overpay_payment_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'Overpay webhook: платеж не найден',
|
||||
merchant_transaction_id=merchant_transaction_id,
|
||||
overpay_payment_id=overpay_payment_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await overpay_crud.get_overpay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Overpay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Проверка дублирования (re-check from locked row)
|
||||
if payment.is_paid:
|
||||
logger.info('Overpay webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = OVERPAY_STATUS_MAP.get(overpay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
callback_payload = {
|
||||
'overpay_payment_id': overpay_payment_id,
|
||||
'merchant_transaction_id': merchant_transaction_id,
|
||||
'status': overpay_status,
|
||||
}
|
||||
|
||||
# Финализируем платеж если оплачен — без промежуточного commit
|
||||
if is_paid:
|
||||
# Inline field assignments to keep FOR UPDATE lock intact
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.overpay_payment_id = overpay_payment_id or payment.overpay_payment_id
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_overpay_payment(
|
||||
db, payment, overpay_payment_id=overpay_payment_id, trigger='webhook'
|
||||
)
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
payment = await overpay_crud.update_overpay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
overpay_payment_id=overpay_payment_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Overpay webhook: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_overpay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
overpay_payment_id: str | None,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock must be acquired by the caller before invoking this method.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'Overpay платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
# Read fresh metadata AFTER lock to avoid stale data
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
# --- Guest purchase flow ---
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=str(overpay_payment_id) if overpay_payment_id else payment.order_id,
|
||||
provider_name='overpay',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
# Ensure paid fields are set (idempotent — caller may have already set them)
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для Overpay', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
# Загружаем промогруппы в асинхронном контексте
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = str(overpay_payment_id) if overpay_payment_id else payment.order_id
|
||||
|
||||
# Проверяем дупликат транзакции
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.OVERPAY,
|
||||
)
|
||||
|
||||
display_name = settings.get_overpay_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.OVERPAY,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await overpay_crud.link_overpay_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('Overpay платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.OVERPAY,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Overpay', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления Overpay', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'\u2705 <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю Overpay', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан Overpay платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_overpay_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Проверяет статус платежа через API."""
|
||||
try:
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
payment = await overpay_crud.get_overpay_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
logger.warning('Overpay payment not found', order_id=order_id)
|
||||
return None
|
||||
|
||||
if payment.is_paid:
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
# Проверяем через API по overpay_payment_id
|
||||
if payment.overpay_payment_id:
|
||||
try:
|
||||
order_data = await overpay_service.get_payment(payment.overpay_payment_id)
|
||||
overpay_status = order_data.get('status')
|
||||
|
||||
if overpay_status:
|
||||
status_info = OVERPAY_STATUS_MAP.get(overpay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
if is_paid:
|
||||
# Acquire FOR UPDATE lock before finalization
|
||||
locked = await overpay_crud.get_overpay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Overpay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return None
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('Overpay платеж уже обработан (api_check)', order_id=payment.order_id)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
logger.info('Overpay payment confirmed via API', order_id=payment.order_id)
|
||||
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.callback_payload = {
|
||||
'check_source': 'api',
|
||||
'overpay_order_data': order_data,
|
||||
}
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await self._finalize_overpay_payment(
|
||||
db,
|
||||
payment,
|
||||
overpay_payment_id=payment.overpay_payment_id,
|
||||
trigger='api_check',
|
||||
)
|
||||
elif internal_status != payment.status:
|
||||
# Обновляем статус если изменился
|
||||
payment = await overpay_crud.update_overpay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error checking Overpay payment status via API', error=e)
|
||||
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': payment.status or 'pending',
|
||||
'is_paid': payment.is_paid,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Overpay: ошибка проверки статуса', error=e)
|
||||
return None
|
||||
@@ -0,0 +1,645 @@
|
||||
"""Mixin для интеграции с PayPear (paypear.ru)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.paypear_service import paypear_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов PayPear -> internal
|
||||
PAYPEAR_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'NEW': ('pending', False),
|
||||
'PROCESS': ('processing', False),
|
||||
'CONFIRMED': ('success', True),
|
||||
'CANCELED': ('canceled', False),
|
||||
'REFUNDED': ('refunded', False),
|
||||
'EXPIRED': ('expired', False),
|
||||
}
|
||||
|
||||
|
||||
class PayPearPaymentMixin:
|
||||
"""Mixin для работы с платежами PayPear."""
|
||||
|
||||
async def create_paypear_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж PayPear.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_paypear_enabled():
|
||||
logger.error('PayPear не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.PAYPEAR_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'PayPear: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
PAYPEAR_MIN_AMOUNT_KOPEKS=settings.PAYPEAR_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.PAYPEAR_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'PayPear: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
PAYPEAR_MAX_AMOUNT_KOPEKS=settings.PAYPEAR_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'pp{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.PAYPEAR_CURRENCY
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
try:
|
||||
payment_method_type = settings.PAYPEAR_PAYMENT_METHOD
|
||||
|
||||
# Используем API для создания платежа
|
||||
result = await paypear_service.create_payment(
|
||||
order_id=order_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
payment_method_type=payment_method_type,
|
||||
description=description,
|
||||
return_url=return_url or settings.PAYPEAR_RETURN_URL,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
confirmation = result.get('confirmation', {})
|
||||
payment_url = (
|
||||
(confirmation.get('confirmation_url') or confirmation.get('url'))
|
||||
if isinstance(confirmation, dict)
|
||||
else None
|
||||
)
|
||||
paypear_id = result.get('id')
|
||||
|
||||
if not payment_url:
|
||||
logger.error('PayPear API не вернул confirmation_url', result=result)
|
||||
return None
|
||||
|
||||
# PayPear может добавить комиссию к сумме — сохраняем фактическую сумму
|
||||
# для корректной проверки в webhook (amount включает комиссию)
|
||||
api_amount = result.get('amount', {})
|
||||
if isinstance(api_amount, dict) and api_amount.get('value') is not None:
|
||||
charged_kopeks = round(float(api_amount['value']) * 100)
|
||||
metadata['paypear_charged_kopeks'] = charged_kopeks
|
||||
|
||||
logger.info(
|
||||
'PayPear API: создан платеж',
|
||||
order_id=order_id,
|
||||
paypear_id=paypear_id,
|
||||
payment_url=payment_url,
|
||||
charged_kopeks=metadata.get('paypear_charged_kopeks'),
|
||||
)
|
||||
|
||||
# Срок действия — 30 минут по умолчанию
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=30)
|
||||
|
||||
# Сохраняем в БД
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
local_payment = await paypear_crud.create_paypear_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method_type,
|
||||
paypear_id=paypear_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'PayPear: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'paypear_id': paypear_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('PayPear: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
async def process_paypear_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает webhook от PayPear.
|
||||
|
||||
Подпись проверяется в webserver/payments.py до вызова этого метода.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело webhook (signature проверена в webserver)
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
event = payload.get('event')
|
||||
obj = payload.get('object', {})
|
||||
paypear_id = obj.get('id')
|
||||
order_id = obj.get('order_id')
|
||||
paypear_status = obj.get('status')
|
||||
|
||||
if not paypear_id or not paypear_status:
|
||||
logger.warning('PayPear webhook: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Определяем is_paid по event
|
||||
is_confirmed = event == 'payment.confirmed'
|
||||
|
||||
# Ищем платеж по order_id (наш) или paypear_id
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
payment = None
|
||||
if order_id:
|
||||
payment = await paypear_crud.get_paypear_payment_by_order_id(db, order_id)
|
||||
if not payment and paypear_id:
|
||||
payment = await paypear_crud.get_paypear_payment_by_paypear_id(db, paypear_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'PayPear webhook: платеж не найден',
|
||||
order_id=order_id,
|
||||
paypear_id=paypear_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await paypear_crud.get_paypear_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('PayPear: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Проверка дублирования (re-check from locked row)
|
||||
if payment.is_paid:
|
||||
logger.info('PayPear webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = PAYPEAR_STATUS_MAP.get(paypear_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
# Если event = payment.confirmed, принудительно считаем оплаченным
|
||||
if is_confirmed:
|
||||
is_paid = True
|
||||
internal_status = 'success'
|
||||
|
||||
callback_payload = {
|
||||
'paypear_id': paypear_id,
|
||||
'order_id': order_id,
|
||||
'status': paypear_status,
|
||||
'event': event,
|
||||
'amount': obj.get('amount'),
|
||||
}
|
||||
|
||||
# Проверка суммы ДО обновления статуса
|
||||
# PayPear добавляет комиссию к amount — сравниваем с сохранённой суммой
|
||||
# (paypear_charged_kopeks), а не с исходной суммой пополнения
|
||||
if is_paid:
|
||||
amount_info = obj.get('amount', {})
|
||||
if isinstance(amount_info, dict):
|
||||
amount_value = amount_info.get('value')
|
||||
else:
|
||||
amount_value = amount_info
|
||||
|
||||
if amount_value is not None:
|
||||
received_kopeks = round(float(amount_value) * 100)
|
||||
payment_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
expected_kopeks = payment_metadata.get('paypear_charged_kopeks', payment.amount_kopeks)
|
||||
if abs(received_kopeks - expected_kopeks) > 1:
|
||||
logger.error(
|
||||
'PayPear amount mismatch',
|
||||
expected_kopeks=expected_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
original_amount_kopeks=payment.amount_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await paypear_crud.update_paypear_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
paypear_id=paypear_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
# Финализируем платеж если оплачен — без промежуточного commit
|
||||
if is_paid:
|
||||
# Inline field assignments to keep FOR UPDATE lock intact
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.paypear_id = paypear_id or payment.paypear_id
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_paypear_payment(db, payment, paypear_id=paypear_id, trigger='webhook')
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
payment = await paypear_crud.update_paypear_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
paypear_id=paypear_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('PayPear webhook: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_paypear_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
paypear_id: str | None,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock must be acquired by the caller before invoking this method.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'PayPear платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
# Read fresh metadata AFTER lock to avoid stale data
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
# --- Guest purchase flow ---
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=str(paypear_id) if paypear_id else payment.order_id,
|
||||
provider_name='paypear',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
# Ensure paid fields are set (idempotent — caller may have already set them)
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для PayPear', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
# Загружаем промогруппы в асинхронном контексте
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = str(paypear_id) if paypear_id else payment.order_id
|
||||
|
||||
# Проверяем дупликат транзакции
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.PAYPEAR,
|
||||
)
|
||||
|
||||
display_name = settings.get_paypear_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.PAYPEAR,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await paypear_crud.link_paypear_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('PayPear платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.PAYPEAR,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения PayPear', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления PayPear', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'\u2705 <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю PayPear', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан PayPear платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_paypear_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Проверяет статус платежа через API."""
|
||||
try:
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
payment = await paypear_crud.get_paypear_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
logger.warning('PayPear payment not found', order_id=order_id)
|
||||
return None
|
||||
|
||||
if payment.is_paid:
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
# Проверяем через API по paypear_id
|
||||
if payment.paypear_id:
|
||||
try:
|
||||
order_data = await paypear_service.get_payment(payment.paypear_id)
|
||||
paypear_status = order_data.get('status')
|
||||
|
||||
if paypear_status:
|
||||
status_info = PAYPEAR_STATUS_MAP.get(paypear_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
if is_paid:
|
||||
# Проверка суммы — сравниваем с paypear_charged_kopeks
|
||||
# (amount включает комиссию PayPear)
|
||||
amount_info = order_data.get('amount', {})
|
||||
api_amount = amount_info.get('value') if isinstance(amount_info, dict) else amount_info
|
||||
if api_amount is not None:
|
||||
received_kopeks = round(float(api_amount) * 100)
|
||||
payment_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
expected_kopeks = payment_metadata.get('paypear_charged_kopeks', payment.amount_kopeks)
|
||||
if abs(received_kopeks - expected_kopeks) > 1:
|
||||
logger.error(
|
||||
'PayPear amount mismatch (API check)',
|
||||
expected_kopeks=expected_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
original_amount_kopeks=payment.amount_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await paypear_crud.update_paypear_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
paypear_id=payment.paypear_id,
|
||||
callback_payload={
|
||||
'check_source': 'api',
|
||||
'paypear_order_data': order_data,
|
||||
},
|
||||
)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'amount_mismatch',
|
||||
'is_paid': False,
|
||||
}
|
||||
|
||||
# Acquire FOR UPDATE lock before finalization
|
||||
locked = await paypear_crud.get_paypear_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('PayPear: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return None
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('PayPear платеж уже обработан (api_check)', order_id=payment.order_id)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
logger.info('PayPear payment confirmed via API', order_id=payment.order_id)
|
||||
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.callback_payload = {
|
||||
'check_source': 'api',
|
||||
'paypear_order_data': order_data,
|
||||
}
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await self._finalize_paypear_payment(
|
||||
db,
|
||||
payment,
|
||||
paypear_id=payment.paypear_id,
|
||||
trigger='api_check',
|
||||
)
|
||||
elif internal_status != payment.status:
|
||||
# Обновляем статус если изменился
|
||||
payment = await paypear_crud.update_paypear_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error checking PayPear payment status via API', error=e)
|
||||
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': payment.status or 'pending',
|
||||
'is_paid': payment.is_paid,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('PayPear: ошибка проверки статуса', error=e)
|
||||
return None
|
||||
@@ -0,0 +1,629 @@
|
||||
"""Mixin для интеграции с RollyPay (rollypay.io)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.rollypay_service import rollypay_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов RollyPay -> internal
|
||||
ROLLYPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'created': ('pending', False),
|
||||
'processing': ('processing', False),
|
||||
'paid': ('success', True),
|
||||
'expired': ('expired', False),
|
||||
'canceled': ('canceled', False),
|
||||
'chargeback': ('chargeback', False),
|
||||
}
|
||||
|
||||
|
||||
class RollyPayPaymentMixin:
|
||||
"""Mixin для работы с платежами RollyPay."""
|
||||
|
||||
async def create_rollypay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
payment_method_type: str | None = None,
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж RollyPay.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_rollypay_enabled():
|
||||
logger.error('RollyPay не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.ROLLYPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'RollyPay: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
ROLLYPAY_MIN_AMOUNT_KOPEKS=settings.ROLLYPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.ROLLYPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'RollyPay: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
ROLLYPAY_MAX_AMOUNT_KOPEKS=settings.ROLLYPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'rp{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
amount_value = f'{amount_rubles:.2f}'
|
||||
currency = settings.ROLLYPAY_CURRENCY
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
try:
|
||||
# Используем API для создания платежа
|
||||
result = await rollypay_service.create_payment(
|
||||
amount_value=amount_value,
|
||||
currency=currency,
|
||||
order_id=order_id,
|
||||
payment_method=payment_method_type,
|
||||
description=description,
|
||||
redirect_url=return_url or settings.ROLLYPAY_RETURN_URL,
|
||||
customer_id=str(tg_id),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
payment_url = result.get('pay_url')
|
||||
rollypay_payment_id = result.get('payment_id')
|
||||
|
||||
if not payment_url:
|
||||
logger.error('RollyPay API не вернул URL платежа', result=result)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'RollyPay API: создан платеж',
|
||||
order_id=order_id,
|
||||
rollypay_payment_id=rollypay_payment_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
# Срок действия из expires_at ответа или 30 минут по умолчанию
|
||||
expires_at_str = result.get('expires_at')
|
||||
if expires_at_str:
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(expires_at_str)
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
except (ValueError, TypeError):
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=30)
|
||||
else:
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=30)
|
||||
|
||||
# Сохраняем в БД
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
local_payment = await rollypay_crud.create_rollypay_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method_type or 'sbp',
|
||||
rollypay_payment_id=rollypay_payment_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'RollyPay: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'rollypay_payment_id': rollypay_payment_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('RollyPay: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
async def process_rollypay_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает webhook от RollyPay.
|
||||
|
||||
Подпись проверяется в webserver/payments.py до вызова этого метода.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело webhook (signature проверена в webserver)
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
event_type = payload.get('event_type')
|
||||
rollypay_payment_id = payload.get('payment_id')
|
||||
order_id = payload.get('order_id')
|
||||
rollypay_status = payload.get('status')
|
||||
|
||||
if not rollypay_payment_id or not rollypay_status:
|
||||
logger.warning('RollyPay webhook: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Определяем is_paid по event
|
||||
is_confirmed = event_type == 'payment.paid'
|
||||
|
||||
# Ищем платеж по order_id (наш) или rollypay_payment_id
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
payment = None
|
||||
if order_id:
|
||||
payment = await rollypay_crud.get_rollypay_payment_by_order_id(db, order_id)
|
||||
if not payment and rollypay_payment_id:
|
||||
payment = await rollypay_crud.get_rollypay_payment_by_rollypay_id(db, rollypay_payment_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'RollyPay webhook: платеж не найден',
|
||||
order_id=order_id,
|
||||
rollypay_payment_id=rollypay_payment_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await rollypay_crud.get_rollypay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('RollyPay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Проверка дублирования (re-check from locked row)
|
||||
if payment.is_paid:
|
||||
logger.info('RollyPay webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = ROLLYPAY_STATUS_MAP.get(rollypay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
# Если event = payment.paid, принудительно считаем оплаченным
|
||||
if is_confirmed:
|
||||
is_paid = True
|
||||
internal_status = 'success'
|
||||
|
||||
callback_payload = {
|
||||
'rollypay_payment_id': rollypay_payment_id,
|
||||
'order_id': order_id,
|
||||
'status': rollypay_status,
|
||||
'event_type': event_type,
|
||||
'amount': payload.get('amount'),
|
||||
'currency': payload.get('currency'),
|
||||
}
|
||||
|
||||
# Проверка суммы ДО обновления статуса
|
||||
if is_paid:
|
||||
amount_value = payload.get('amount')
|
||||
if amount_value is not None:
|
||||
received_kopeks = round(float(amount_value) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'RollyPay amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await rollypay_crud.update_rollypay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
rollypay_payment_id=rollypay_payment_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
# Финализируем платеж если оплачен — без промежуточного commit
|
||||
if is_paid:
|
||||
# Inline field assignments to keep FOR UPDATE lock intact
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.rollypay_payment_id = rollypay_payment_id or payment.rollypay_payment_id
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_rollypay_payment(
|
||||
db, payment, rollypay_payment_id=rollypay_payment_id, trigger='webhook'
|
||||
)
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
payment = await rollypay_crud.update_rollypay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
rollypay_payment_id=rollypay_payment_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('RollyPay webhook: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_rollypay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
rollypay_payment_id: str | None,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock must be acquired by the caller before invoking this method.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'RollyPay платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
# Read fresh metadata AFTER lock to avoid stale data
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
# --- Guest purchase flow ---
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=str(rollypay_payment_id) if rollypay_payment_id else payment.order_id,
|
||||
provider_name='rollypay',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
# Ensure paid fields are set (idempotent — caller may have already set them)
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для RollyPay', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
# Загружаем промогруппы в асинхронном контексте
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = str(rollypay_payment_id) if rollypay_payment_id else payment.order_id
|
||||
|
||||
# Проверяем дупликат транзакции
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.ROLLYPAY,
|
||||
)
|
||||
|
||||
display_name = settings.get_rollypay_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.ROLLYPAY,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await rollypay_crud.link_rollypay_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('RollyPay платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.ROLLYPAY,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения RollyPay', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления RollyPay', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'\u2705 <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю RollyPay', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан RollyPay платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_rollypay_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Проверяет статус платежа через API."""
|
||||
try:
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
payment = await rollypay_crud.get_rollypay_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
logger.warning('RollyPay payment not found', order_id=order_id)
|
||||
return None
|
||||
|
||||
if payment.is_paid:
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
# Проверяем через API по rollypay_payment_id
|
||||
if payment.rollypay_payment_id:
|
||||
try:
|
||||
order_data = await rollypay_service.get_payment(payment.rollypay_payment_id)
|
||||
rollypay_status = order_data.get('status')
|
||||
|
||||
if rollypay_status:
|
||||
status_info = ROLLYPAY_STATUS_MAP.get(rollypay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
if is_paid:
|
||||
# Проверка суммы
|
||||
api_amount = order_data.get('amount')
|
||||
if api_amount is not None:
|
||||
received_kopeks = round(float(api_amount) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'RollyPay amount mismatch (API check)',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await rollypay_crud.update_rollypay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
rollypay_payment_id=payment.rollypay_payment_id,
|
||||
callback_payload={
|
||||
'check_source': 'api',
|
||||
'rollypay_order_data': order_data,
|
||||
},
|
||||
)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'amount_mismatch',
|
||||
'is_paid': False,
|
||||
}
|
||||
|
||||
# Acquire FOR UPDATE lock before finalization
|
||||
locked = await rollypay_crud.get_rollypay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('RollyPay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return None
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('RollyPay платеж уже обработан (api_check)', order_id=payment.order_id)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
logger.info('RollyPay payment confirmed via API', order_id=payment.order_id)
|
||||
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.callback_payload = {
|
||||
'check_source': 'api',
|
||||
'rollypay_order_data': order_data,
|
||||
}
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await self._finalize_rollypay_payment(
|
||||
db,
|
||||
payment,
|
||||
rollypay_payment_id=payment.rollypay_payment_id,
|
||||
trigger='api_check',
|
||||
)
|
||||
elif internal_status != payment.status:
|
||||
# Обновляем статус если изменился
|
||||
payment = await rollypay_crud.update_rollypay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error checking RollyPay payment status via API', error=e)
|
||||
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': payment.status or 'pending',
|
||||
'is_paid': payment.is_paid,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('RollyPay: ошибка проверки статуса', error=e)
|
||||
return None
|
||||
@@ -286,6 +286,13 @@ class TelegramStarsMixin:
|
||||
sync_error=sync_error,
|
||||
exc_info=True,
|
||||
)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=subscription.user_id,
|
||||
action='create',
|
||||
)
|
||||
|
||||
period_display = period_days
|
||||
if not period_display and getattr(subscription, 'start_date', None) and getattr(subscription, 'end_date', None):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user