Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+51
-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
|
||||
@@ -680,6 +684,52 @@ 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=
|
||||
|
||||
# ===== WATA =====
|
||||
WATA_ENABLED=false
|
||||
WATA_BASE_URL=https://api.wata.pro
|
||||
|
||||
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.2"
|
||||
".": "3.48.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,88 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.45.2" # x-release-please-version
|
||||
ARG VERSION="v3.48.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
+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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3023,6 +3023,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
|
||||
|
||||
+29
-10
@@ -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(
|
||||
@@ -1052,6 +1058,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 +1073,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 +1188,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,66 @@ 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',
|
||||
)
|
||||
|
||||
else:
|
||||
# For other payment methods, redirect to bot
|
||||
raise HTTPException(
|
||||
|
||||
@@ -28,7 +28,7 @@ class NotificationSettingsResponse(BaseModel):
|
||||
subscription_expiry_days: int = 3
|
||||
traffic_warning_enabled: bool = True
|
||||
traffic_warning_percent: int = 80
|
||||
balance_low_enabled: bool = True
|
||||
balance_low_enabled: bool = False
|
||||
balance_low_threshold: int = 100 # kopeks
|
||||
news_enabled: bool = True
|
||||
promo_offers_enabled: bool = True
|
||||
@@ -60,7 +60,7 @@ def _get_notification_settings(user: User) -> dict[str, Any]:
|
||||
'subscription_expiry_days': settings_data.get('subscription_expiry_days', 3),
|
||||
'traffic_warning_enabled': settings_data.get('traffic_warning_enabled', True),
|
||||
'traffic_warning_percent': settings_data.get('traffic_warning_percent', 80),
|
||||
'balance_low_enabled': settings_data.get('balance_low_enabled', True),
|
||||
'balance_low_enabled': settings_data.get('balance_low_enabled', False),
|
||||
'balance_low_threshold': settings_data.get('balance_low_threshold', 100),
|
||||
'news_enabled': settings_data.get('news_enabled', True),
|
||||
'promo_offers_enabled': settings_data.get('promo_offers_enabled', True),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -228,12 +228,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:
|
||||
@@ -469,12 +481,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)
|
||||
|
||||
|
||||
@@ -911,6 +911,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:
|
||||
@@ -1228,6 +1235,13 @@ async def activate_trial(
|
||||
except Exception as e:
|
||||
logger.error('Error getting trial tariff', error=e)
|
||||
|
||||
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
|
||||
if not trial_squads:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
trial_squads = [trial_squad_uuid] if trial_squad_uuid else []
|
||||
|
||||
# Create trial subscription
|
||||
subscription = await create_trial_subscription(
|
||||
db=db,
|
||||
@@ -1249,6 +1263,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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -428,6 +428,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}'
|
||||
|
||||
+55
-2
@@ -68,9 +68,9 @@ class Settings(BaseSettings):
|
||||
ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID: int | None = None # Партнёрки, выводы, админ-действия
|
||||
|
||||
# Настройки очереди чеков NaloGO
|
||||
NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды)
|
||||
NALOGO_QUEUE_CHECK_INTERVAL: int = 600 # Интервал проверки очереди (секунды, 10 мин)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS: int = 10 # Максимум попыток отправки чека
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS: int = 72 # Максимум попыток отправки чека (72 × 10мин = 12 часов)
|
||||
|
||||
ADMIN_REPORTS_ENABLED: bool = False
|
||||
ADMIN_REPORTS_CHAT_ID: str | None = None
|
||||
@@ -324,6 +324,7 @@ class Settings(BaseSettings):
|
||||
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS: int = 20000
|
||||
|
||||
MONITORING_INTERVAL: int = 60
|
||||
LOW_BALANCE_ALERT_EXPIRY_DAYS: int = 3 # Only alert when subscription expires within N days
|
||||
INACTIVE_USER_DELETE_MONTHS: int = 3
|
||||
|
||||
MAINTENANCE_MODE: bool = False
|
||||
@@ -436,6 +437,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
|
||||
@@ -586,6 +588,29 @@ 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
|
||||
|
||||
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
|
||||
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
|
||||
CABINET_BUTTON_STYLE: str = ''
|
||||
@@ -825,6 +850,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 +1001,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.
|
||||
|
||||
@@ -1963,6 +1996,26 @@ 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_kassa_ai_sbp_enabled(self) -> bool:
|
||||
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
+132
-8
@@ -162,6 +162,8 @@ class PaymentMethod(Enum):
|
||||
KASSA_AI = 'kassa_ai'
|
||||
RIOPAY = 'riopay'
|
||||
SEVERPAY = 'severpay'
|
||||
PAYPEAR = 'paypear'
|
||||
ROLLYPAY = 'rollypay'
|
||||
MANUAL = 'manual'
|
||||
BALANCE = 'balance'
|
||||
|
||||
@@ -878,6 +880,130 @@ 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 PromoGroup(Base):
|
||||
__tablename__ = 'promo_groups'
|
||||
|
||||
@@ -933,13 +1059,6 @@ class PromoGroup(Base):
|
||||
if period_days in discounts:
|
||||
return discounts[period_days]
|
||||
|
||||
# For daily tariffs (period_days=1): fallback to the smallest configured period discount.
|
||||
# Admins configure discounts for standard periods (30, 90, 180, 360) but not for daily.
|
||||
# If all periods have 100% discount, daily should too.
|
||||
if period_days <= 1 and discounts:
|
||||
smallest_period = min(discounts)
|
||||
return discounts[smallest_period]
|
||||
|
||||
if self.is_default:
|
||||
try:
|
||||
from app.config import settings
|
||||
@@ -1211,6 +1330,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
|
||||
@@ -1262,7 +1383,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)
|
||||
|
||||
# Ограничения пользователя
|
||||
@@ -2245,6 +2366,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)
|
||||
|
||||
Vendored
+1
-1
@@ -446,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
|
||||
|
||||
@@ -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}',
|
||||
),
|
||||
],
|
||||
|
||||
+31
-19
@@ -3821,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)
|
||||
@@ -3910,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)
|
||||
|
||||
@@ -149,6 +149,20 @@ 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 == 'riopay':
|
||||
from .riopay import process_riopay_payment_amount
|
||||
|
||||
@@ -719,6 +733,14 @@ 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 .mulenpay import check_mulenpay_payment_status
|
||||
|
||||
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Handler for PayPear balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_paypear_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating PayPear payment and sending response.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_paypear_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_paypear_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'\U0001f4b3 Оплатить {amount}\u20bd',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'PAYPEAR_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}\u20bd</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info('PayPear payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_paypear_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.PAYPEAR_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.PAYPEAR_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}\u20bd',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}\u20bd',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_paypear_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_paypear_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start PayPear top-up process - ask for amount.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='paypear')
|
||||
|
||||
min_amount = settings.PAYPEAR_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.PAYPEAR_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_paypear_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'PAYPEAR_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}\u20bd\n'
|
||||
'Максимум: {max_amount}\u20bd',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Handler for RollyPay balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_rollypay_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating RollyPay payment and sending response.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_rollypay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_rollypay_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'\U0001f4b3 Оплатить {amount}\u20bd',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'ROLLYPAY_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}\u20bd</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info('RollyPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_rollypay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.ROLLYPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.ROLLYPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}\u20bd',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}\u20bd',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_rollypay_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_rollypay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start RollyPay top-up process - ask for amount.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='rollypay')
|
||||
|
||||
min_amount = settings.ROLLYPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.ROLLYPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_rollypay_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'ROLLYPAY_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}\u20bd\n'
|
||||
'Максимум: {max_amount}\u20bd',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
@@ -169,7 +169,9 @@ async def show_main_menu(
|
||||
await db.commit()
|
||||
|
||||
# Multi-tariff aware: check if user has ANY active subscription
|
||||
has_active_subscription = any(sub.is_active for sub in (getattr(db_user, 'subscriptions', None) or []))
|
||||
# 'limited' (traffic exhausted) subscriptions are still active for UI purposes
|
||||
_subs = getattr(db_user, 'subscriptions', None) or []
|
||||
has_active_subscription = any(sub.is_active or getattr(sub, 'actual_status', None) == 'limited' for sub in _subs)
|
||||
subscription_is_active = has_active_subscription
|
||||
|
||||
menu_text = await get_main_menu_text(db_user, texts, db)
|
||||
@@ -1013,7 +1015,9 @@ async def handle_back_to_menu(callback: types.CallbackQuery, state: FSMContext,
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Multi-tariff aware: check if user has ANY active subscription
|
||||
has_active_subscription = any(sub.is_active for sub in (getattr(db_user, 'subscriptions', None) or []))
|
||||
# 'limited' (traffic exhausted) subscriptions are still active for UI purposes
|
||||
_subs = getattr(db_user, 'subscriptions', None) or []
|
||||
has_active_subscription = any(sub.is_active or getattr(sub, 'actual_status', None) == 'limited' for sub in _subs)
|
||||
subscription_is_active = has_active_subscription
|
||||
|
||||
menu_text = await get_main_menu_text(db_user, texts, db)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -937,6 +937,13 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа', error=e)
|
||||
|
||||
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
|
||||
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,
|
||||
@@ -2579,17 +2586,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 +2606,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 +3174,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:
|
||||
@@ -3303,6 +3322,13 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
|
||||
|
||||
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
|
||||
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,
|
||||
@@ -4594,6 +4620,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(
|
||||
|
||||
@@ -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(
|
||||
@@ -2280,14 +2343,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(
|
||||
@@ -2893,14 +2976,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)
|
||||
@@ -3121,14 +3224,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)
|
||||
@@ -3795,14 +3918,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', 'Уведомление закрыто.'))
|
||||
|
||||
|
||||
|
||||
@@ -1807,6 +1807,30 @@ 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_support_topup_enabled():
|
||||
keyboard.append(
|
||||
[
|
||||
|
||||
@@ -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
@@ -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 ''
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -264,6 +264,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 +547,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:
|
||||
|
||||
@@ -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',
|
||||
@@ -1960,6 +1983,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:
|
||||
|
||||
@@ -12,8 +12,10 @@ from .heleket import HeleketPaymentMixin
|
||||
from .kassa_ai import KassaAiPaymentMixin
|
||||
from .mulenpay import MulenPayPaymentMixin
|
||||
from .pal24 import Pal24PaymentMixin
|
||||
from .paypear import PayPearPaymentMixin
|
||||
from .platega import PlategaPaymentMixin
|
||||
from .riopay import RioPayPaymentMixin
|
||||
from .rollypay import RollyPayPaymentMixin
|
||||
from .severpay import SeverPayPaymentMixin
|
||||
from .stars import TelegramStarsMixin
|
||||
from .tribute import TributePaymentMixin
|
||||
@@ -29,9 +31,11 @@ __all__ = [
|
||||
'KassaAiPaymentMixin',
|
||||
'MulenPayPaymentMixin',
|
||||
'Pal24PaymentMixin',
|
||||
'PayPearPaymentMixin',
|
||||
'PaymentCommonMixin',
|
||||
'PlategaPaymentMixin',
|
||||
'RioPayPaymentMixin',
|
||||
'RollyPayPaymentMixin',
|
||||
'SeverPayPaymentMixin',
|
||||
'TelegramStarsMixin',
|
||||
'TributePaymentMixin',
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
"""Mixin для интеграции с PayPear (paypear.ru)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.paypear_service import paypear_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов PayPear -> internal
|
||||
PAYPEAR_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'NEW': ('pending', False),
|
||||
'PROCESS': ('processing', False),
|
||||
'CONFIRMED': ('success', True),
|
||||
'CANCELED': ('canceled', False),
|
||||
'REFUNDED': ('refunded', False),
|
||||
'EXPIRED': ('expired', False),
|
||||
}
|
||||
|
||||
|
||||
class PayPearPaymentMixin:
|
||||
"""Mixin для работы с платежами PayPear."""
|
||||
|
||||
async def create_paypear_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж PayPear.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_paypear_enabled():
|
||||
logger.error('PayPear не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.PAYPEAR_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'PayPear: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
PAYPEAR_MIN_AMOUNT_KOPEKS=settings.PAYPEAR_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.PAYPEAR_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'PayPear: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
PAYPEAR_MAX_AMOUNT_KOPEKS=settings.PAYPEAR_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'pp{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.PAYPEAR_CURRENCY
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
try:
|
||||
payment_method_type = settings.PAYPEAR_PAYMENT_METHOD
|
||||
|
||||
# Используем API для создания платежа
|
||||
result = await paypear_service.create_payment(
|
||||
order_id=order_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
payment_method_type=payment_method_type,
|
||||
description=description,
|
||||
return_url=return_url or settings.PAYPEAR_RETURN_URL,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
confirmation = result.get('confirmation', {})
|
||||
payment_url = confirmation.get('url') if isinstance(confirmation, dict) else None
|
||||
paypear_id = result.get('id')
|
||||
|
||||
if not payment_url:
|
||||
logger.error('PayPear API не вернул URL платежа', result=result)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'PayPear API: создан платеж',
|
||||
order_id=order_id,
|
||||
paypear_id=paypear_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
# Срок действия — 30 минут по умолчанию
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=30)
|
||||
|
||||
# Сохраняем в БД
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
local_payment = await paypear_crud.create_paypear_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method_type,
|
||||
paypear_id=paypear_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'PayPear: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'paypear_id': paypear_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('PayPear: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
async def process_paypear_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает webhook от PayPear.
|
||||
|
||||
Подпись проверяется в webserver/payments.py до вызова этого метода.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело webhook (signature проверена в webserver)
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
event = payload.get('event')
|
||||
obj = payload.get('object', {})
|
||||
paypear_id = obj.get('id')
|
||||
order_id = obj.get('order_id')
|
||||
paypear_status = obj.get('status')
|
||||
|
||||
if not paypear_id or not paypear_status:
|
||||
logger.warning('PayPear webhook: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Определяем is_paid по event
|
||||
is_confirmed = event == 'payment.confirmed'
|
||||
|
||||
# Ищем платеж по order_id (наш) или paypear_id
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
payment = None
|
||||
if order_id:
|
||||
payment = await paypear_crud.get_paypear_payment_by_order_id(db, order_id)
|
||||
if not payment and paypear_id:
|
||||
payment = await paypear_crud.get_paypear_payment_by_paypear_id(db, paypear_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'PayPear webhook: платеж не найден',
|
||||
order_id=order_id,
|
||||
paypear_id=paypear_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await paypear_crud.get_paypear_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('PayPear: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Проверка дублирования (re-check from locked row)
|
||||
if payment.is_paid:
|
||||
logger.info('PayPear webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = PAYPEAR_STATUS_MAP.get(paypear_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
# Если event = payment.confirmed, принудительно считаем оплаченным
|
||||
if is_confirmed:
|
||||
is_paid = True
|
||||
internal_status = 'success'
|
||||
|
||||
callback_payload = {
|
||||
'paypear_id': paypear_id,
|
||||
'order_id': order_id,
|
||||
'status': paypear_status,
|
||||
'event': event,
|
||||
'amount': obj.get('amount'),
|
||||
}
|
||||
|
||||
# Проверка суммы ДО обновления статуса
|
||||
if is_paid:
|
||||
amount_info = obj.get('amount', {})
|
||||
if isinstance(amount_info, dict):
|
||||
amount_value = amount_info.get('value')
|
||||
else:
|
||||
amount_value = amount_info
|
||||
|
||||
if amount_value is not None:
|
||||
received_kopeks = round(float(amount_value) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'PayPear amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await paypear_crud.update_paypear_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
paypear_id=paypear_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
# Финализируем платеж если оплачен — без промежуточного commit
|
||||
if is_paid:
|
||||
# Inline field assignments to keep FOR UPDATE lock intact
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.paypear_id = paypear_id or payment.paypear_id
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_paypear_payment(db, payment, paypear_id=paypear_id, trigger='webhook')
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
payment = await paypear_crud.update_paypear_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
paypear_id=paypear_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('PayPear webhook: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_paypear_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
paypear_id: str | None,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock must be acquired by the caller before invoking this method.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'PayPear платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
# Read fresh metadata AFTER lock to avoid stale data
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
# --- Guest purchase flow ---
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=str(paypear_id) if paypear_id else payment.order_id,
|
||||
provider_name='paypear',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
# Ensure paid fields are set (idempotent — caller may have already set them)
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для PayPear', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
# Загружаем промогруппы в асинхронном контексте
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = str(paypear_id) if paypear_id else payment.order_id
|
||||
|
||||
# Проверяем дупликат транзакции
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.PAYPEAR,
|
||||
)
|
||||
|
||||
display_name = settings.get_paypear_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.PAYPEAR,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await paypear_crud.link_paypear_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('PayPear платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.PAYPEAR,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения PayPear', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления PayPear', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'\u2705 <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю PayPear', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан PayPear платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_paypear_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Проверяет статус платежа через API."""
|
||||
try:
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
payment = await paypear_crud.get_paypear_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
logger.warning('PayPear payment not found', order_id=order_id)
|
||||
return None
|
||||
|
||||
if payment.is_paid:
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
# Проверяем через API по paypear_id
|
||||
if payment.paypear_id:
|
||||
try:
|
||||
order_data = await paypear_service.get_payment(payment.paypear_id)
|
||||
paypear_status = order_data.get('status')
|
||||
|
||||
if paypear_status:
|
||||
status_info = PAYPEAR_STATUS_MAP.get(paypear_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
if is_paid:
|
||||
# Проверка суммы
|
||||
amount_info = order_data.get('amount', {})
|
||||
api_amount = amount_info.get('value') if isinstance(amount_info, dict) else amount_info
|
||||
if api_amount is not None:
|
||||
received_kopeks = round(float(api_amount) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'PayPear amount mismatch (API check)',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await paypear_crud.update_paypear_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
paypear_id=payment.paypear_id,
|
||||
callback_payload={
|
||||
'check_source': 'api',
|
||||
'paypear_order_data': order_data,
|
||||
},
|
||||
)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'amount_mismatch',
|
||||
'is_paid': False,
|
||||
}
|
||||
|
||||
# Acquire FOR UPDATE lock before finalization
|
||||
locked = await paypear_crud.get_paypear_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('PayPear: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return None
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('PayPear платеж уже обработан (api_check)', order_id=payment.order_id)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
logger.info('PayPear payment confirmed via API', order_id=payment.order_id)
|
||||
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.callback_payload = {
|
||||
'check_source': 'api',
|
||||
'paypear_order_data': order_data,
|
||||
}
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await self._finalize_paypear_payment(
|
||||
db,
|
||||
payment,
|
||||
paypear_id=payment.paypear_id,
|
||||
trigger='api_check',
|
||||
)
|
||||
elif internal_status != payment.status:
|
||||
# Обновляем статус если изменился
|
||||
payment = await paypear_crud.update_paypear_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error checking PayPear payment status via API', error=e)
|
||||
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': payment.status or 'pending',
|
||||
'is_paid': payment.is_paid,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('PayPear: ошибка проверки статуса', error=e)
|
||||
return None
|
||||
@@ -0,0 +1,629 @@
|
||||
"""Mixin для интеграции с RollyPay (rollypay.io)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.rollypay_service import rollypay_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов RollyPay -> internal
|
||||
ROLLYPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'created': ('pending', False),
|
||||
'processing': ('processing', False),
|
||||
'paid': ('success', True),
|
||||
'expired': ('expired', False),
|
||||
'canceled': ('canceled', False),
|
||||
'chargeback': ('chargeback', False),
|
||||
}
|
||||
|
||||
|
||||
class RollyPayPaymentMixin:
|
||||
"""Mixin для работы с платежами RollyPay."""
|
||||
|
||||
async def create_rollypay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
payment_method_type: str | None = None,
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж RollyPay.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_rollypay_enabled():
|
||||
logger.error('RollyPay не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.ROLLYPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'RollyPay: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
ROLLYPAY_MIN_AMOUNT_KOPEKS=settings.ROLLYPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.ROLLYPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'RollyPay: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
ROLLYPAY_MAX_AMOUNT_KOPEKS=settings.ROLLYPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'rp{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
amount_value = f'{amount_rubles:.2f}'
|
||||
currency = settings.ROLLYPAY_CURRENCY
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
try:
|
||||
# Используем API для создания платежа
|
||||
result = await rollypay_service.create_payment(
|
||||
amount_value=amount_value,
|
||||
currency=currency,
|
||||
order_id=order_id,
|
||||
payment_method=payment_method_type,
|
||||
description=description,
|
||||
redirect_url=return_url or settings.ROLLYPAY_RETURN_URL,
|
||||
customer_id=str(tg_id),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
payment_url = result.get('pay_url')
|
||||
rollypay_payment_id = result.get('payment_id')
|
||||
|
||||
if not payment_url:
|
||||
logger.error('RollyPay API не вернул URL платежа', result=result)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'RollyPay API: создан платеж',
|
||||
order_id=order_id,
|
||||
rollypay_payment_id=rollypay_payment_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
# Срок действия из expires_at ответа или 30 минут по умолчанию
|
||||
expires_at_str = result.get('expires_at')
|
||||
if expires_at_str:
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(expires_at_str)
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
except (ValueError, TypeError):
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=30)
|
||||
else:
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=30)
|
||||
|
||||
# Сохраняем в БД
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
local_payment = await rollypay_crud.create_rollypay_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method_type or 'sbp',
|
||||
rollypay_payment_id=rollypay_payment_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'RollyPay: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'rollypay_payment_id': rollypay_payment_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('RollyPay: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
async def process_rollypay_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает webhook от RollyPay.
|
||||
|
||||
Подпись проверяется в webserver/payments.py до вызова этого метода.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело webhook (signature проверена в webserver)
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
event_type = payload.get('event_type')
|
||||
rollypay_payment_id = payload.get('payment_id')
|
||||
order_id = payload.get('order_id')
|
||||
rollypay_status = payload.get('status')
|
||||
|
||||
if not rollypay_payment_id or not rollypay_status:
|
||||
logger.warning('RollyPay webhook: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Определяем is_paid по event
|
||||
is_confirmed = event_type == 'payment.paid'
|
||||
|
||||
# Ищем платеж по order_id (наш) или rollypay_payment_id
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
payment = None
|
||||
if order_id:
|
||||
payment = await rollypay_crud.get_rollypay_payment_by_order_id(db, order_id)
|
||||
if not payment and rollypay_payment_id:
|
||||
payment = await rollypay_crud.get_rollypay_payment_by_rollypay_id(db, rollypay_payment_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'RollyPay webhook: платеж не найден',
|
||||
order_id=order_id,
|
||||
rollypay_payment_id=rollypay_payment_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await rollypay_crud.get_rollypay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('RollyPay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Проверка дублирования (re-check from locked row)
|
||||
if payment.is_paid:
|
||||
logger.info('RollyPay webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = ROLLYPAY_STATUS_MAP.get(rollypay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
# Если event = payment.paid, принудительно считаем оплаченным
|
||||
if is_confirmed:
|
||||
is_paid = True
|
||||
internal_status = 'success'
|
||||
|
||||
callback_payload = {
|
||||
'rollypay_payment_id': rollypay_payment_id,
|
||||
'order_id': order_id,
|
||||
'status': rollypay_status,
|
||||
'event_type': event_type,
|
||||
'amount': payload.get('amount'),
|
||||
'currency': payload.get('currency'),
|
||||
}
|
||||
|
||||
# Проверка суммы ДО обновления статуса
|
||||
if is_paid:
|
||||
amount_value = payload.get('amount')
|
||||
if amount_value is not None:
|
||||
received_kopeks = round(float(amount_value) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'RollyPay amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await rollypay_crud.update_rollypay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
rollypay_payment_id=rollypay_payment_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
# Финализируем платеж если оплачен — без промежуточного commit
|
||||
if is_paid:
|
||||
# Inline field assignments to keep FOR UPDATE lock intact
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.rollypay_payment_id = rollypay_payment_id or payment.rollypay_payment_id
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_rollypay_payment(
|
||||
db, payment, rollypay_payment_id=rollypay_payment_id, trigger='webhook'
|
||||
)
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
payment = await rollypay_crud.update_rollypay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
rollypay_payment_id=rollypay_payment_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('RollyPay webhook: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_rollypay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
rollypay_payment_id: str | None,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock must be acquired by the caller before invoking this method.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'RollyPay платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
# Read fresh metadata AFTER lock to avoid stale data
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
# --- Guest purchase flow ---
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=str(rollypay_payment_id) if rollypay_payment_id else payment.order_id,
|
||||
provider_name='rollypay',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
# Ensure paid fields are set (idempotent — caller may have already set them)
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для RollyPay', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
# Загружаем промогруппы в асинхронном контексте
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = str(rollypay_payment_id) if rollypay_payment_id else payment.order_id
|
||||
|
||||
# Проверяем дупликат транзакции
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.ROLLYPAY,
|
||||
)
|
||||
|
||||
display_name = settings.get_rollypay_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.ROLLYPAY,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await rollypay_crud.link_rollypay_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('RollyPay платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.ROLLYPAY,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения RollyPay', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления RollyPay', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'\u2705 <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю RollyPay', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан RollyPay платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_rollypay_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Проверяет статус платежа через API."""
|
||||
try:
|
||||
rollypay_crud = import_module('app.database.crud.rollypay')
|
||||
payment = await rollypay_crud.get_rollypay_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
logger.warning('RollyPay payment not found', order_id=order_id)
|
||||
return None
|
||||
|
||||
if payment.is_paid:
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
# Проверяем через API по rollypay_payment_id
|
||||
if payment.rollypay_payment_id:
|
||||
try:
|
||||
order_data = await rollypay_service.get_payment(payment.rollypay_payment_id)
|
||||
rollypay_status = order_data.get('status')
|
||||
|
||||
if rollypay_status:
|
||||
status_info = ROLLYPAY_STATUS_MAP.get(rollypay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
if is_paid:
|
||||
# Проверка суммы
|
||||
api_amount = order_data.get('amount')
|
||||
if api_amount is not None:
|
||||
received_kopeks = round(float(api_amount) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'RollyPay amount mismatch (API check)',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await rollypay_crud.update_rollypay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
rollypay_payment_id=payment.rollypay_payment_id,
|
||||
callback_payload={
|
||||
'check_source': 'api',
|
||||
'rollypay_order_data': order_data,
|
||||
},
|
||||
)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'amount_mismatch',
|
||||
'is_paid': False,
|
||||
}
|
||||
|
||||
# Acquire FOR UPDATE lock before finalization
|
||||
locked = await rollypay_crud.get_rollypay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('RollyPay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return None
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('RollyPay платеж уже обработан (api_check)', order_id=payment.order_id)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
logger.info('RollyPay payment confirmed via API', order_id=payment.order_id)
|
||||
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.callback_payload = {
|
||||
'check_source': 'api',
|
||||
'rollypay_order_data': order_data,
|
||||
}
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await self._finalize_rollypay_payment(
|
||||
db,
|
||||
payment,
|
||||
rollypay_payment_id=payment.rollypay_payment_id,
|
||||
trigger='api_check',
|
||||
)
|
||||
elif internal_status != payment.status:
|
||||
# Обновляем статус если изменился
|
||||
payment = await rollypay_crud.update_rollypay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error checking RollyPay payment status via API', error=e)
|
||||
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': payment.status or 'pending',
|
||||
'is_paid': payment.is_paid,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('RollyPay: ошибка проверки статуса', error=e)
|
||||
return None
|
||||
@@ -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):
|
||||
|
||||
@@ -680,6 +680,13 @@ class YooKassaPaymentMixin:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as rw_error:
|
||||
logger.error('Ошибка создания RemnaWave для триала', rw_error=rw_error)
|
||||
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',
|
||||
)
|
||||
|
||||
# Уведомление админам
|
||||
if getattr(self, 'bot', None):
|
||||
@@ -991,6 +998,13 @@ class YooKassaPaymentMixin:
|
||||
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',
|
||||
)
|
||||
|
||||
# Отправляем уведомление пользователю об активации подписки (только Telegram)
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
|
||||
@@ -146,6 +146,29 @@ def _get_method_defaults() -> dict:
|
||||
'default_max': settings.SEVERPAY_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': None,
|
||||
},
|
||||
'paypear': {
|
||||
'default_display_name': settings.get_paypear_display_name(),
|
||||
'is_configured': settings.is_paypear_enabled(),
|
||||
'default_min': settings.PAYPEAR_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.PAYPEAR_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'bank_card', 'name': 'Карта'},
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
{'id': 'sberpay', 'name': 'SberPay'},
|
||||
{'id': 'tpay', 'name': 'T-Pay'},
|
||||
],
|
||||
},
|
||||
'rollypay': {
|
||||
'default_display_name': settings.get_rollypay_display_name(),
|
||||
'is_configured': settings.is_rollypay_enabled(),
|
||||
'default_min': settings.ROLLYPAY_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.ROLLYPAY_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'crypto', 'name': 'Криптовалюта'},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -188,6 +211,8 @@ DEFAULT_METHOD_ORDER = [
|
||||
'kassa_ai',
|
||||
'riopay',
|
||||
'severpay',
|
||||
'paypear',
|
||||
'rollypay',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,9 @@ from app.services.payment import (
|
||||
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
|
||||
from app.services.payment.freekassa import FreekassaPaymentMixin
|
||||
from app.services.payment.kassa_ai import KassaAiPaymentMixin
|
||||
from app.services.payment.paypear import PayPearPaymentMixin
|
||||
from app.services.payment.riopay import RioPayPaymentMixin
|
||||
from app.services.payment.rollypay import RollyPayPaymentMixin
|
||||
from app.services.payment.severpay import SeverPayPaymentMixin
|
||||
from app.services.platega_service import PlategaService
|
||||
from app.services.wata_service import WataService
|
||||
@@ -332,6 +334,41 @@ async def link_severpay_payment_to_transaction(*args, **kwargs):
|
||||
return await severpay_crud.link_severpay_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_paypear_payment(*args, **kwargs):
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
return await paypear_crud.create_paypear_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_paypear_payment_by_order_id(*args, **kwargs):
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
return await paypear_crud.get_paypear_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_paypear_payment_by_paypear_id(*args, **kwargs):
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
return await paypear_crud.get_paypear_payment_by_paypear_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_paypear_payment_by_id(*args, **kwargs):
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
return await paypear_crud.get_paypear_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_paypear_payment_by_id_for_update(*args, **kwargs):
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
return await paypear_crud.get_paypear_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_paypear_payment_status(*args, **kwargs):
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
return await paypear_crud.update_paypear_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_paypear_payment_to_transaction(*args, **kwargs):
|
||||
paypear_crud = import_module('app.database.crud.paypear')
|
||||
return await paypear_crud.link_paypear_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
# Mapping from model_name to getter function name for providers
|
||||
# where it differs from the standard get_{model_name}_payment_by_id pattern.
|
||||
_GETTER_OVERRIDES: dict[str, str] = {
|
||||
@@ -355,6 +392,8 @@ class PaymentService(
|
||||
KassaAiPaymentMixin,
|
||||
RioPayPaymentMixin,
|
||||
SeverPayPaymentMixin,
|
||||
PayPearPaymentMixin,
|
||||
RollyPayPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
|
||||
@@ -774,6 +813,28 @@ class PaymentService(
|
||||
}
|
||||
return None
|
||||
|
||||
# --- PayPear ----------------------------------------------------------
|
||||
if payment_method == 'paypear':
|
||||
if not settings.is_paypear_enabled():
|
||||
logger.warning('PayPear is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_paypear_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'paypear')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('paypear_id') or result.get('order_id'),
|
||||
'provider': 'paypear',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Telegram Stars ---------------------------------------------------
|
||||
if payment_method == 'telegram_stars':
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Сервис для работы с API PayPear (paypear.ru)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
from base64 import b64encode
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
API_BASE_URL = 'https://api.paypear.ru/v1'
|
||||
|
||||
|
||||
class PayPearAPIError(Exception):
|
||||
"""Ошибка API PayPear."""
|
||||
|
||||
def __init__(self, status_code: int, message: str):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
super().__init__(f'PayPear API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class PayPearService:
|
||||
"""Сервис для работы с API PayPear."""
|
||||
|
||||
def __init__(self):
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def shop_id(self) -> str:
|
||||
return settings.PAYPEAR_SHOP_ID or ''
|
||||
|
||||
@property
|
||||
def secret_key(self) -> str:
|
||||
return settings.PAYPEAR_SECRET_KEY or ''
|
||||
|
||||
def _basic_auth_header(self) -> str:
|
||||
"""Генерирует HTTP Basic Auth заголовок."""
|
||||
credentials = f'{self.shop_id}:{self.secret_key}'
|
||||
encoded = b64encode(credentials.encode('utf-8')).decode('utf-8')
|
||||
return f'Basic {encoded}'
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Возвращает переиспользуемую HTTP-сессию."""
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрывает HTTP-сессию."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
order_id: str,
|
||||
amount_rubles: float,
|
||||
currency: str = 'RUB',
|
||||
payment_method_type: str = 'sbp',
|
||||
description: str = '',
|
||||
return_url: str | None = None,
|
||||
webhook_url: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Создает платеж через API PayPear.
|
||||
POST /v1/payment/
|
||||
"""
|
||||
idempotence_key = str(uuid.uuid4())
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
'amount': {
|
||||
'value': f'{amount_rubles:.2f}',
|
||||
'currency': currency,
|
||||
},
|
||||
'order_id': order_id,
|
||||
'payment_method_data': {
|
||||
'type': payment_method_type,
|
||||
},
|
||||
}
|
||||
|
||||
if description:
|
||||
payload['description'] = description
|
||||
if return_url:
|
||||
payload['confirmation'] = {
|
||||
'type': 'redirect',
|
||||
'return_url': return_url,
|
||||
}
|
||||
if webhook_url:
|
||||
payload['webhook_url'] = webhook_url
|
||||
if metadata:
|
||||
payload['metadata'] = metadata
|
||||
|
||||
logger.info(
|
||||
'PayPear API create_payment',
|
||||
order_id=order_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
f'{API_BASE_URL}/payment/',
|
||||
json=payload,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': self._basic_auth_header(),
|
||||
'Idempotence-Key': idempotence_key,
|
||||
},
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status == 200 and data.get('success') is True:
|
||||
result = data.get('result', {})
|
||||
logger.info(
|
||||
'PayPear API payment created',
|
||||
order_id=order_id,
|
||||
paypear_id=result.get('id'),
|
||||
)
|
||||
return result
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'PayPear create_payment error',
|
||||
status_code=response.status,
|
||||
error_msg=error_msg,
|
||||
response_data=data,
|
||||
)
|
||||
raise PayPearAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('PayPear API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def get_payment(self, payment_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Получает информацию о платеже по ID.
|
||||
GET /v1/payment/{id}/
|
||||
"""
|
||||
logger.info('PayPear get_payment', payment_id=payment_id)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.get(
|
||||
f'{API_BASE_URL}/payment/{payment_id}/',
|
||||
headers={
|
||||
'Authorization': self._basic_auth_header(),
|
||||
},
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status == 200 and data.get('success') is True:
|
||||
return data.get('result', {})
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'PayPear get_payment error',
|
||||
status_code=response.status,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
raise PayPearAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('PayPear API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def get_payment_by_order_id(self, order_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Получает информацию о платеже по order_id.
|
||||
GET /v1/payment/order/{order_id}/
|
||||
"""
|
||||
logger.info('PayPear get_payment_by_order_id', order_id=order_id)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.get(
|
||||
f'{API_BASE_URL}/payment/order/{order_id}/',
|
||||
headers={
|
||||
'Authorization': self._basic_auth_header(),
|
||||
},
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status == 200 and data.get('success') is True:
|
||||
return data.get('result', {})
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'PayPear get_payment_by_order_id error',
|
||||
status_code=response.status,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
raise PayPearAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('PayPear API connection error', error=e)
|
||||
raise
|
||||
|
||||
def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool:
|
||||
"""Верификация подписи webhook PayPear через HMAC-SHA256.
|
||||
|
||||
PayPear sends signature in the webhook JSON field 'signature'.
|
||||
The signature is HMAC-SHA256(secret_key, raw_body).
|
||||
"""
|
||||
try:
|
||||
if not received_signature:
|
||||
logger.warning('PayPear webhook: отсутствует signature')
|
||||
return False
|
||||
|
||||
expected = hmac.new(
|
||||
self.secret_key.encode('utf-8'),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(expected, received_signature)
|
||||
except Exception as e:
|
||||
logger.error('PayPear webhook verify error', error=e)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
paypear_service = PayPearService()
|
||||
@@ -117,6 +117,14 @@ async def claim_phantom(
|
||||
subscription_id=sub.id,
|
||||
exc_info=True,
|
||||
)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
if hasattr(sub, 'id') and hasattr(sub, 'user_id'):
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=sub.id,
|
||||
user_id=sub.user_id,
|
||||
action='update',
|
||||
)
|
||||
|
||||
return True, phantom
|
||||
|
||||
@@ -205,3 +213,12 @@ async def sync_remnawave_after_phantom_merge(db: AsyncSession, user: User) -> No
|
||||
user_id=user.id,
|
||||
exc_info=True,
|
||||
)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
for sub in subs:
|
||||
if hasattr(sub, 'id') and hasattr(sub, 'user_id'):
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=sub.id,
|
||||
user_id=sub.user_id,
|
||||
action='update',
|
||||
)
|
||||
|
||||
@@ -216,6 +216,14 @@ class PromoOfferService:
|
||||
subscription_id=subscription.id,
|
||||
exc=exc,
|
||||
)
|
||||
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.commit()
|
||||
for payload in log_payloads:
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
||||
from app.database.models import User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def resync_user_subscriptions_with_panel(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
) -> dict[str, Any]:
|
||||
"""Resync all active subscriptions for a user with the RemnaWave panel.
|
||||
|
||||
Should be called after any identity change (TG linking, account merge,
|
||||
email verification) to ensure the panel has up-to-date telegram_id,
|
||||
email, and squads.
|
||||
|
||||
Returns a stats dict with keys:
|
||||
synced - number of subscriptions successfully synced
|
||||
failed - number of subscriptions that failed to sync
|
||||
total - total number of active subscriptions found
|
||||
skipped - True when the panel is not configured
|
||||
"""
|
||||
service = SubscriptionService()
|
||||
service._refresh_configuration()
|
||||
|
||||
if not service.is_configured:
|
||||
logger.warning(
|
||||
'remnawave_resync: panel not configured, skipping resync',
|
||||
user_id=user.id,
|
||||
config_error=service.configuration_error,
|
||||
)
|
||||
return {'synced': 0, 'failed': 0, 'total': 0, 'skipped': True}
|
||||
|
||||
subscriptions = await get_active_subscriptions_by_user_id(db, int(user.id))
|
||||
|
||||
if not subscriptions:
|
||||
logger.info(
|
||||
'remnawave_resync: no active subscriptions found',
|
||||
user_id=user.id,
|
||||
)
|
||||
return {'synced': 0, 'failed': 0, 'total': 0, 'skipped': False}
|
||||
|
||||
synced = 0
|
||||
failed = 0
|
||||
|
||||
for subscription in subscriptions:
|
||||
# Eagerly refresh tariff to avoid lazy-loading in async context.
|
||||
try:
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
'remnawave_resync: could not refresh tariff for subscription',
|
||||
subscription_id=subscription.id,
|
||||
error=exc,
|
||||
)
|
||||
|
||||
# Determine whether a panel user already exists for this subscription.
|
||||
if settings.is_multi_tariff_enabled():
|
||||
panel_user_exists = bool(subscription.remnawave_uuid)
|
||||
else:
|
||||
panel_user_exists = bool(user.remnawave_uuid)
|
||||
|
||||
try:
|
||||
if panel_user_exists:
|
||||
result = await service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
sync_squads=True,
|
||||
)
|
||||
else:
|
||||
result = await service.create_remnawave_user(db, subscription)
|
||||
|
||||
if result is not None:
|
||||
synced += 1
|
||||
logger.info(
|
||||
'remnawave_resync: subscription synced',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='update' if panel_user_exists else 'create',
|
||||
)
|
||||
else:
|
||||
failed += 1
|
||||
logger.warning(
|
||||
'remnawave_resync: subscription sync returned None',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='update' if panel_user_exists else 'create',
|
||||
)
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
logger.error(
|
||||
'remnawave_resync: unexpected error syncing subscription',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
error=exc,
|
||||
)
|
||||
|
||||
total = len(subscriptions)
|
||||
logger.info(
|
||||
'remnawave_resync: completed',
|
||||
user_id=user.id,
|
||||
total=total,
|
||||
synced=synced,
|
||||
failed=failed,
|
||||
)
|
||||
|
||||
return {'synced': synced, 'failed': failed, 'total': total, 'skipped': False}
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Deferred retry queue for failed RemnaWave API calls.
|
||||
|
||||
When create_remnawave_user() fails during purchase, the subscription exists
|
||||
in the bot DB but not in the panel. This queue retries the operation
|
||||
periodically until it succeeds or max retries are exhausted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetryItem:
|
||||
subscription_id: int
|
||||
user_id: int
|
||||
action: Literal['create', 'update']
|
||||
attempts: int = 0
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
class RemnaWaveRetryQueue:
|
||||
def __init__(self, max_retries: int = 5, interval_seconds: int = 120) -> None:
|
||||
self._queue: deque[RetryItem] = deque()
|
||||
self._max_retries = max_retries
|
||||
self._interval = interval_seconds
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
@property
|
||||
def pending_count(self) -> int:
|
||||
return len(self._queue)
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
subscription_id: int,
|
||||
user_id: int,
|
||||
action: Literal['create', 'update'] = 'create',
|
||||
) -> None:
|
||||
# Deduplicate by subscription_id
|
||||
for item in self._queue:
|
||||
if item.subscription_id == subscription_id:
|
||||
return
|
||||
self._queue.append(
|
||||
RetryItem(
|
||||
subscription_id=subscription_id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
'Enqueued RemnaWave retry',
|
||||
subscription_id=subscription_id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
queue_size=len(self._queue),
|
||||
)
|
||||
|
||||
async def process_pending(self) -> None:
|
||||
if not self._queue:
|
||||
return
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_id
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
batch = list(self._queue)
|
||||
self._queue.clear()
|
||||
|
||||
for item in batch:
|
||||
item.attempts += 1
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
sub = await get_subscription_by_id(db, item.subscription_id)
|
||||
if not sub:
|
||||
logger.warning(
|
||||
'Retry: subscription not found, dropping',
|
||||
subscription_id=item.subscription_id,
|
||||
)
|
||||
continue
|
||||
|
||||
service = SubscriptionService()
|
||||
if not service.is_configured:
|
||||
self._requeue(item, 'RemnaWave not configured')
|
||||
continue
|
||||
|
||||
if item.action == 'create':
|
||||
await service.create_remnawave_user(db, sub)
|
||||
else:
|
||||
await service.update_remnawave_user(db, sub)
|
||||
|
||||
logger.info(
|
||||
'Retry succeeded',
|
||||
subscription_id=item.subscription_id,
|
||||
attempts=item.attempts,
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
self._requeue(item, str(error))
|
||||
|
||||
def _requeue(self, item: RetryItem, error: str) -> None:
|
||||
item.last_error = error
|
||||
if item.attempts < self._max_retries:
|
||||
self._queue.append(item)
|
||||
logger.warning(
|
||||
'Retry failed, re-enqueued',
|
||||
subscription_id=item.subscription_id,
|
||||
attempts=item.attempts,
|
||||
max_retries=self._max_retries,
|
||||
error=error,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
'Retry exhausted, dropping (MANUAL INTERVENTION NEEDED)',
|
||||
subscription_id=item.subscription_id,
|
||||
user_id=item.user_id,
|
||||
attempts=item.attempts,
|
||||
error=error,
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
return
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(self._interval)
|
||||
await self.process_pending()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
|
||||
# Global instance
|
||||
remnawave_retry_queue = RemnaWaveRetryQueue()
|
||||
@@ -1516,6 +1516,9 @@ class RemnaWaveService:
|
||||
for telegram_id, db_user in bot_users_by_telegram_id.items()
|
||||
if telegram_id not in panel_telegram_ids
|
||||
and any(True for _ in (getattr(db_user, 'subscriptions', None) or []))
|
||||
# BUG-6 fix: Skip users who have a remnawave_uuid — they exist in panel
|
||||
# but may not have telegram_id set there (OAuth users who linked TG later)
|
||||
and not getattr(db_user, 'remnawave_uuid', None)
|
||||
]
|
||||
|
||||
if users_to_deactivate:
|
||||
@@ -1878,6 +1881,20 @@ class RemnaWaveService:
|
||||
|
||||
_short_id = await generate_unique_short_id(db)
|
||||
|
||||
# Attempt to match tariff by allowed_squads
|
||||
_matched_tariff_id = None
|
||||
if _squad_uuids:
|
||||
try:
|
||||
from app.database.crud.tariff import get_all_active_tariffs
|
||||
|
||||
_all_tariffs = await get_all_active_tariffs(db)
|
||||
for _t in _all_tariffs:
|
||||
if _t.allowed_squads and set(_squad_uuids).issubset(set(_t.allowed_squads)):
|
||||
_matched_tariff_id = _t.id
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_sub = Subscription(
|
||||
user_id=_bot_user.id,
|
||||
status=_sub_status.value,
|
||||
@@ -1892,6 +1909,7 @@ class RemnaWaveService:
|
||||
remnawave_short_uuid=panel_user.get('shortUuid'),
|
||||
subscription_url=panel_user.get('subscriptionUrl', ''),
|
||||
subscription_crypto_link=panel_user.get('subscriptionCryptoLink', ''),
|
||||
tariff_id=_matched_tariff_id,
|
||||
)
|
||||
db.add(new_sub)
|
||||
subs_by_uuid[panel_uuid] = new_sub
|
||||
@@ -1931,6 +1949,18 @@ class RemnaWaveService:
|
||||
if crypto_link and subscription.subscription_crypto_link != crypto_link:
|
||||
subscription.subscription_crypto_link = crypto_link
|
||||
|
||||
# Update squads from panel
|
||||
_panel_squads = panel_user.get('activeInternalSquads', []) or []
|
||||
_squad_uuids = []
|
||||
if isinstance(_panel_squads, list):
|
||||
for _sq in _panel_squads:
|
||||
if isinstance(_sq, dict) and 'uuid' in _sq:
|
||||
_squad_uuids.append(_sq['uuid'])
|
||||
elif isinstance(_sq, str):
|
||||
_squad_uuids.append(_sq)
|
||||
if _squad_uuids and set(_squad_uuids) != set(subscription.connected_squads or []):
|
||||
subscription.connected_squads = _squad_uuids
|
||||
|
||||
stats['updated'] += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
@@ -2146,6 +2176,24 @@ class RemnaWaveService:
|
||||
|
||||
# traffic_limit_gb, device_limit: bot is source of truth, do not overwrite from panel
|
||||
|
||||
# Update connected_squads from panel (panel is source of truth for squad assignments)
|
||||
active_squads = panel_user.get('activeInternalSquads', [])
|
||||
panel_squad_uuids = []
|
||||
if isinstance(active_squads, list):
|
||||
for squad in active_squads:
|
||||
if isinstance(squad, dict) and 'uuid' in squad:
|
||||
panel_squad_uuids.append(squad['uuid'])
|
||||
elif isinstance(squad, str):
|
||||
panel_squad_uuids.append(squad)
|
||||
|
||||
if panel_squad_uuids and set(panel_squad_uuids) != set(subscription.connected_squads or []):
|
||||
subscription.connected_squads = panel_squad_uuids
|
||||
logger.info(
|
||||
'Обновлены connected_squads из панели',
|
||||
user_telegram_id=getattr(user, 'telegram_id', '?'),
|
||||
new_squads=panel_squad_uuids,
|
||||
)
|
||||
|
||||
new_short_uuid = panel_user.get('shortUuid')
|
||||
if new_short_uuid and subscription.remnawave_short_uuid != new_short_uuid:
|
||||
old_short_uuid = subscription.remnawave_short_uuid
|
||||
|
||||
@@ -1304,6 +1304,13 @@ class RemnaWaveWebhookService:
|
||||
async def _handle_bandwidth_threshold(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
# Respect user notification preferences
|
||||
from app.utils.notification_prefs import is_traffic_warning_enabled
|
||||
|
||||
if not is_traffic_warning_enabled(user):
|
||||
logger.debug('Traffic warning disabled by user prefs', user_id=user.id)
|
||||
return
|
||||
|
||||
# Extract threshold percentage from meta or data
|
||||
percent = data.get('thresholdPercent') or data.get('threshold', '')
|
||||
if not percent:
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Сервис для работы с API RollyPay (rollypay.io)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
API_BASE_URL = 'https://rollypay.io/api/v1'
|
||||
|
||||
|
||||
class RollyPayAPIError(Exception):
|
||||
"""Ошибка API RollyPay."""
|
||||
|
||||
def __init__(self, status_code: int, message: str):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
super().__init__(f'RollyPay API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class RollyPayService:
|
||||
"""Сервис для работы с API RollyPay."""
|
||||
|
||||
def __init__(self):
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
return settings.ROLLYPAY_API_KEY or ''
|
||||
|
||||
@property
|
||||
def signing_secret(self) -> str:
|
||||
return settings.ROLLYPAY_SIGNING_SECRET or ''
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Возвращает переиспользуемую HTTP-сессию."""
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрывает HTTP-сессию."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
def _build_headers(self) -> dict[str, str]:
|
||||
"""Строит заголовки запроса с X-API-Key и X-Nonce."""
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': self.api_key,
|
||||
'X-Nonce': str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
amount_value: str,
|
||||
currency: str = 'RUB',
|
||||
order_id: str,
|
||||
payment_method: str | None = None,
|
||||
description: str = '',
|
||||
redirect_url: str | None = None,
|
||||
success_redirect_url: str | None = None,
|
||||
fail_redirect_url: str | None = None,
|
||||
customer_id: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Создает платеж через API RollyPay.
|
||||
POST /api/v1/payments
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'amount': amount_value,
|
||||
'payment_currency': currency,
|
||||
'order_id': order_id,
|
||||
}
|
||||
if payment_method:
|
||||
payload['payment_method'] = payment_method
|
||||
|
||||
if description:
|
||||
payload['description'] = description
|
||||
if redirect_url:
|
||||
payload['redirect_url'] = redirect_url
|
||||
if success_redirect_url:
|
||||
payload['success_redirect_url'] = success_redirect_url
|
||||
if fail_redirect_url:
|
||||
payload['fail_redirect_url'] = fail_redirect_url
|
||||
if customer_id:
|
||||
payload['customer_id'] = customer_id
|
||||
if metadata:
|
||||
payload['metadata'] = metadata
|
||||
|
||||
logger.info(
|
||||
'RollyPay API create_payment',
|
||||
order_id=order_id,
|
||||
amount=amount_value,
|
||||
currency=currency,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
f'{API_BASE_URL}/payments',
|
||||
json=payload,
|
||||
headers=self._build_headers(),
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status == 200:
|
||||
logger.info(
|
||||
'RollyPay API payment created',
|
||||
order_id=order_id,
|
||||
payment_id=data.get('payment_id'),
|
||||
pay_url=data.get('pay_url'),
|
||||
)
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'RollyPay create_payment error',
|
||||
status_code=response.status,
|
||||
error_msg=error_msg,
|
||||
response_data=data,
|
||||
)
|
||||
raise RollyPayAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('RollyPay API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def get_payment(self, payment_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Получает информацию о платеже по ID.
|
||||
GET /api/v1/payments/{paymentID}
|
||||
"""
|
||||
logger.info('RollyPay get_payment', payment_id=payment_id)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.get(
|
||||
f'{API_BASE_URL}/payments/{payment_id}',
|
||||
headers=self._build_headers(),
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status == 200:
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'RollyPay get_payment error',
|
||||
status_code=response.status,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
raise RollyPayAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('RollyPay API connection error', error=e)
|
||||
raise
|
||||
|
||||
def verify_webhook_signature(self, raw_body: bytes, received_signature: str, timestamp: str) -> bool:
|
||||
"""Верификация подписи webhook RollyPay через HMAC-SHA256.
|
||||
|
||||
Signature = HMAC-SHA256(signing_secret, f"{timestamp}.{raw_body_str}")
|
||||
Headers: X-Signature, X-Timestamp
|
||||
"""
|
||||
try:
|
||||
if not received_signature or not timestamp:
|
||||
logger.warning('RollyPay webhook: отсутствует signature или timestamp')
|
||||
return False
|
||||
|
||||
raw_body_str = raw_body.decode('utf-8')
|
||||
message = f'{timestamp}.{raw_body_str}'
|
||||
|
||||
expected = hmac.new(
|
||||
self.signing_secret.encode('utf-8'),
|
||||
message.encode('utf-8'),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(expected, received_signature)
|
||||
except Exception as e:
|
||||
logger.error('RollyPay webhook verify error', error=e)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
rollypay_service = RollyPayService()
|
||||
@@ -575,7 +575,7 @@ async def _auto_extend_subscription(
|
||||
updated_subscription,
|
||||
reset_traffic=should_reset_traffic,
|
||||
reset_reason='смена тарифа' if is_tariff_change else 'продление подписки',
|
||||
sync_squads=is_tariff_change,
|
||||
sync_squads=True,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
@@ -583,6 +583,14 @@ async def _auto_extend_subscription(
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
if hasattr(updated_subscription, 'id') and hasattr(updated_subscription, 'user_id'):
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=updated_subscription.id,
|
||||
user_id=updated_subscription.user_id,
|
||||
action='update',
|
||||
)
|
||||
|
||||
await _delete_cart_for_subscription(user.id, cart_data)
|
||||
await clear_subscription_checkout_draft(user.id)
|
||||
@@ -953,6 +961,14 @@ async def _auto_purchase_tariff(
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
)
|
||||
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',
|
||||
)
|
||||
|
||||
# Очищаем корзину (per-subscription if subscription_id is in cart)
|
||||
await _delete_cart_for_subscription(user.id, cart_data)
|
||||
@@ -1303,6 +1319,14 @@ async def _auto_purchase_daily_tariff(
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
)
|
||||
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',
|
||||
)
|
||||
|
||||
# Очищаем корзину (per-subscription if subscription_id is in cart)
|
||||
await _delete_cart_for_subscription(user.id, cart_data)
|
||||
@@ -1634,6 +1658,14 @@ async def _auto_add_devices(
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
)
|
||||
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',
|
||||
)
|
||||
|
||||
# Очищаем корзину (транзакция уже создана в subtract_user_balance)
|
||||
await _delete_cart_for_subscription(user.id, cart_data)
|
||||
@@ -1982,6 +2014,14 @@ async def _auto_add_traffic(
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
)
|
||||
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',
|
||||
)
|
||||
|
||||
# Clear cart (transaction already created in subtract_user_balance)
|
||||
await _delete_cart_for_subscription(user.id, cart_data)
|
||||
@@ -2333,6 +2373,14 @@ async def try_auto_extend_expired_after_topup(
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
if hasattr(updated_subscription, 'id') and hasattr(updated_subscription, 'user_id'):
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=updated_subscription.id,
|
||||
user_id=updated_subscription.user_id,
|
||||
action='update',
|
||||
)
|
||||
|
||||
texts = get_texts(getattr(user, 'language', 'ru'))
|
||||
period_label = format_period_description(period_days, getattr(user, 'language', 'ru'))
|
||||
@@ -2713,6 +2761,14 @@ async def try_resume_disabled_daily_after_topup(
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
)
|
||||
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',
|
||||
)
|
||||
|
||||
# Admin notification
|
||||
try:
|
||||
|
||||
@@ -1166,12 +1166,22 @@ class MiniAppSubscriptionPurchaseService:
|
||||
logger.warning('Failed to disable trial on RemnaWave', error=trial_err, trial_id=trial_sub.id)
|
||||
|
||||
try:
|
||||
_purch_uuid = (
|
||||
subscription.remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
|
||||
else getattr(user, 'remnawave_uuid', None)
|
||||
)
|
||||
if _purch_uuid:
|
||||
# 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(user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='miniapp purchase',
|
||||
)
|
||||
else:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
@@ -1179,15 +1189,15 @@ class MiniAppSubscriptionPurchaseService:
|
||||
reset_reason='miniapp purchase',
|
||||
sync_squads=True,
|
||||
)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='miniapp purchase',
|
||||
)
|
||||
except Exception as remnawave_error: # pragma: no cover - defensive logging
|
||||
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 getattr(subscription, 'remnawave_uuid', None) else 'update',
|
||||
)
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
|
||||
@@ -491,20 +491,20 @@ class SubscriptionRenewalService:
|
||||
subscription_service = SubscriptionService()
|
||||
try:
|
||||
await db.refresh(user)
|
||||
_renew_uuid = (
|
||||
subscription_after.remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled() and subscription_after.remnawave_uuid
|
||||
else getattr(user, 'remnawave_uuid', None)
|
||||
)
|
||||
if _renew_uuid:
|
||||
await subscription_service.update_remnawave_user(
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription_after.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription_after,
|
||||
reset_traffic=reset_traffic,
|
||||
reset_reason='subscription renewal',
|
||||
)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription_after,
|
||||
reset_traffic=reset_traffic,
|
||||
@@ -518,6 +518,13 @@ class SubscriptionRenewalService:
|
||||
subscription_after_id=subscription_after.id,
|
||||
error=error,
|
||||
)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription_after.id,
|
||||
user_id=subscription_after.user_id,
|
||||
action='create' if not getattr(subscription_after, 'remnawave_uuid', None) else 'update',
|
||||
)
|
||||
|
||||
transaction: Transaction | None = None
|
||||
try:
|
||||
|
||||
@@ -403,7 +403,7 @@ class SubscriptionService:
|
||||
*,
|
||||
reset_traffic: bool = False,
|
||||
reset_reason: str | None = None,
|
||||
sync_squads: bool = False,
|
||||
sync_squads: bool = True,
|
||||
) -> RemnaWaveUser | None:
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
|
||||
@@ -768,6 +768,14 @@ class UserService:
|
||||
subscription_id=sub.id,
|
||||
error=e,
|
||||
)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
if hasattr(sub, 'id') and hasattr(sub, 'user_id'):
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=sub.id,
|
||||
user_id=sub.user_id,
|
||||
action='update',
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info('Админ разблокировал пользователя', admin_id=admin_id, user_id=user_id)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""User notification preferences helper.
|
||||
|
||||
Reads notification_settings JSON from User model and provides
|
||||
typed access to individual preferences with sensible defaults.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.database.models import User
|
||||
|
||||
|
||||
# Defaults match the frontend and cabinet/routes/notifications.py
|
||||
_DEFAULTS: dict[str, Any] = {
|
||||
'subscription_expiry_enabled': True,
|
||||
'subscription_expiry_days': 3,
|
||||
'traffic_warning_enabled': True,
|
||||
'traffic_warning_percent': 80,
|
||||
'balance_low_enabled': False,
|
||||
'balance_low_threshold': 100, # kopeks
|
||||
'news_enabled': True,
|
||||
'promo_offers_enabled': True,
|
||||
}
|
||||
|
||||
|
||||
def get_user_notification_pref(user: User, key: str) -> Any:
|
||||
"""Get a single notification preference for user.
|
||||
|
||||
Falls back to default if not set.
|
||||
"""
|
||||
settings_data = getattr(user, 'notification_settings', None) or {}
|
||||
return settings_data.get(key, _DEFAULTS.get(key))
|
||||
|
||||
|
||||
def is_subscription_expiry_enabled(user: User) -> bool:
|
||||
"""Check if subscription expiry notifications are enabled for user."""
|
||||
return bool(get_user_notification_pref(user, 'subscription_expiry_enabled'))
|
||||
|
||||
|
||||
def get_subscription_expiry_days(user: User) -> int:
|
||||
"""Get the number of days before expiry to notify."""
|
||||
value = get_user_notification_pref(user, 'subscription_expiry_days')
|
||||
try:
|
||||
return max(1, int(value))
|
||||
except (TypeError, ValueError):
|
||||
return 3
|
||||
|
||||
|
||||
def is_traffic_warning_enabled(user: User) -> bool:
|
||||
"""Check if traffic warning notifications are enabled for user."""
|
||||
return bool(get_user_notification_pref(user, 'traffic_warning_enabled'))
|
||||
|
||||
|
||||
def get_traffic_warning_percent(user: User) -> int:
|
||||
"""Get the traffic usage percentage threshold for warning."""
|
||||
value = get_user_notification_pref(user, 'traffic_warning_percent')
|
||||
try:
|
||||
return max(50, min(99, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 80
|
||||
|
||||
|
||||
def is_balance_low_enabled(user: User) -> bool:
|
||||
"""Check if low balance notifications are enabled for user."""
|
||||
return bool(get_user_notification_pref(user, 'balance_low_enabled'))
|
||||
|
||||
|
||||
def get_balance_low_threshold(user: User) -> int:
|
||||
"""Get the low balance threshold in kopeks."""
|
||||
value = get_user_notification_pref(user, 'balance_low_threshold')
|
||||
try:
|
||||
return max(0, int(value))
|
||||
except (TypeError, ValueError):
|
||||
return 100
|
||||
|
||||
|
||||
def is_news_enabled(user: User) -> bool:
|
||||
"""Check if news notifications are enabled for user."""
|
||||
return bool(get_user_notification_pref(user, 'news_enabled'))
|
||||
|
||||
|
||||
def is_promo_offers_enabled(user: User) -> bool:
|
||||
"""Check if promo offer notifications are enabled for user."""
|
||||
return bool(get_user_notification_pref(user, 'promo_offers_enabled'))
|
||||
@@ -7055,6 +7055,14 @@ async def switch_tariff_endpoint(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка синхронизации с 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',
|
||||
)
|
||||
|
||||
lang = getattr(user, 'language', settings.DEFAULT_LANGUAGE)
|
||||
if upgrade_cost > 0:
|
||||
@@ -7245,6 +7253,14 @@ async def purchase_traffic_topup_endpoint(
|
||||
await service.enable_remnawave_user(_en_uuid)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка синхронизации с 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',
|
||||
)
|
||||
|
||||
# Создаем транзакцию
|
||||
await create_transaction(
|
||||
@@ -7484,6 +7500,14 @@ async def toggle_daily_subscription_pause_endpoint(
|
||||
logger.warning('Failed to sync squads after user creation (miniapp)', error=squad_err)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка синхронизации с 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',
|
||||
)
|
||||
|
||||
# Send admin notification about daily subscription resume
|
||||
if resume_transaction is not None:
|
||||
|
||||
@@ -18,7 +18,9 @@ def _normalize_period_discounts(value: dict[object, object] | None) -> dict[int,
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
return normalized or None
|
||||
# Return empty dict (not None) so the backend can distinguish
|
||||
# "clear all discounts" ({}) from "don't touch discounts" (None/absent).
|
||||
return normalized
|
||||
|
||||
|
||||
class PromoGroupResponse(BaseModel):
|
||||
|
||||
@@ -1241,6 +1241,105 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# PayPear webhook
|
||||
if settings.is_paypear_enabled():
|
||||
|
||||
@router.get(settings.PAYPEAR_WEBHOOK_PATH)
|
||||
async def paypear_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'paypear_webhook',
|
||||
'enabled': settings.is_paypear_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.PAYPEAR_WEBHOOK_PATH)
|
||||
async def paypear_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('PayPear webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Извлекаем подпись из тела webhook
|
||||
received_signature = payload.get('signature', '')
|
||||
|
||||
from app.services.paypear_service import paypear_service
|
||||
|
||||
if not paypear_service.verify_webhook_signature(raw_body, received_signature):
|
||||
logger.warning('PayPear webhook: invalid signature')
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_paypear_webhook',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'PayPear webhook processing failed',
|
||||
data=payload.get('object'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('PayPear webhook processing error', error=e)
|
||||
# Always return 200 — PayPear may retry on non-200
|
||||
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# RollyPay webhook
|
||||
if settings.is_rollypay_enabled():
|
||||
|
||||
@router.get(settings.ROLLYPAY_WEBHOOK_PATH)
|
||||
async def rollypay_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'rollypay_webhook',
|
||||
'enabled': settings.is_rollypay_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.ROLLYPAY_WEBHOOK_PATH)
|
||||
async def rollypay_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('RollyPay webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Подпись через заголовки X-Signature и X-Timestamp
|
||||
received_signature = request.headers.get('X-Signature', '')
|
||||
timestamp = request.headers.get('X-Timestamp', '')
|
||||
|
||||
from app.services.rollypay_service import rollypay_service
|
||||
|
||||
if not rollypay_service.verify_webhook_signature(raw_body, received_signature, timestamp):
|
||||
logger.warning('RollyPay webhook: invalid signature')
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_rollypay_webhook',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'RollyPay webhook processing failed',
|
||||
data=payload.get('payment_id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('RollyPay webhook processing error', error=e)
|
||||
# Always return 200 — RollyPay retries on non-200 with exponential backoff
|
||||
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
if routes_registered:
|
||||
|
||||
@router.get('/health/payment-webhooks')
|
||||
@@ -1261,6 +1360,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
'kassa_ai_enabled': settings.is_kassa_ai_enabled(),
|
||||
'riopay_enabled': settings.is_riopay_enabled(),
|
||||
'severpay_enabled': settings.is_severpay_enabled(),
|
||||
'paypear_enabled': settings.is_paypear_enabled(),
|
||||
'rollypay_enabled': settings.is_rollypay_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add broadcast category column
|
||||
|
||||
Revision ID: 0054
|
||||
Revises: 0053
|
||||
Create Date: 2026-04-10
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0054'
|
||||
down_revision: Union[str, None] = '0053'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'broadcast_history',
|
||||
sa.Column('category', sa.String(20), nullable=False, server_default='system'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('broadcast_history', 'category')
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add pending_campaign_slug to users
|
||||
|
||||
Revision ID: 0055
|
||||
Revises: 0054
|
||||
Create Date: 2026-04-13
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0055'
|
||||
down_revision: Union[str, None] = '0054'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'users',
|
||||
sa.Column('pending_campaign_slug', sa.String(64), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('users', 'pending_campaign_slug')
|
||||
@@ -0,0 +1,57 @@
|
||||
"""create cabinet_refresh_tokens table
|
||||
|
||||
Revision ID: 0056
|
||||
Revises: 0055
|
||||
Create Date: 2026-04-13
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0056'
|
||||
down_revision: Union[str, None] = '0055'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _has_table(table: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
return table in inspector.get_table_names()
|
||||
|
||||
|
||||
def _has_index(table: str, index_name: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
return index_name in [idx['name'] for idx in inspector.get_indexes(table)]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_table('cabinet_refresh_tokens'):
|
||||
op.create_table(
|
||||
'cabinet_refresh_tokens',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('token_hash', sa.String(255), nullable=False),
|
||||
sa.Column('device_info', sa.String(500), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
if not _has_index('cabinet_refresh_tokens', 'ix_cabinet_refresh_tokens_id'):
|
||||
op.create_index('ix_cabinet_refresh_tokens_id', 'cabinet_refresh_tokens', ['id'])
|
||||
if not _has_index('cabinet_refresh_tokens', 'ix_cabinet_refresh_tokens_token_hash'):
|
||||
op.create_index('ix_cabinet_refresh_tokens_token_hash', 'cabinet_refresh_tokens', ['token_hash'], unique=True)
|
||||
if not _has_index('cabinet_refresh_tokens', 'ix_cabinet_refresh_tokens_user'):
|
||||
op.create_index('ix_cabinet_refresh_tokens_user', 'cabinet_refresh_tokens', ['user_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_cabinet_refresh_tokens_user', table_name='cabinet_refresh_tokens')
|
||||
op.drop_index('ix_cabinet_refresh_tokens_token_hash', table_name='cabinet_refresh_tokens')
|
||||
op.drop_index('ix_cabinet_refresh_tokens_id', table_name='cabinet_refresh_tokens')
|
||||
op.drop_table('cabinet_refresh_tokens')
|
||||
@@ -0,0 +1,52 @@
|
||||
"""alter notification_settings from json to jsonb
|
||||
|
||||
Revision ID: 0057
|
||||
Revises: 0056
|
||||
Create Date: 2026-04-13
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0057'
|
||||
down_revision: Union[str, None] = '0056'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _get_column_type(table: str, column: str) -> str | None:
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT data_type FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{'table': table, 'column': column},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
col_type = _get_column_type('users', 'notification_settings')
|
||||
if col_type and col_type != 'jsonb':
|
||||
op.execute(
|
||||
sa.text(
|
||||
"ALTER TABLE users "
|
||||
"ALTER COLUMN notification_settings TYPE jsonb "
|
||||
"USING notification_settings::jsonb"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"ALTER TABLE users "
|
||||
"ALTER COLUMN notification_settings TYPE json "
|
||||
"USING notification_settings::json"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""create paypear_payments table
|
||||
|
||||
Revision ID: 0058
|
||||
Revises: 0057
|
||||
Create Date: 2026-04-15
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0058'
|
||||
down_revision: Union[str, None] = '0057'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'paypear_payments',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True),
|
||||
sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True),
|
||||
sa.Column('paypear_id', sa.String(64), unique=True, nullable=True, index=True),
|
||||
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
|
||||
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
|
||||
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||
sa.Column('payment_url', sa.Text(), nullable=True),
|
||||
sa.Column('payment_method', sa.String(32), nullable=True),
|
||||
sa.Column('metadata_json', sa.JSON(), nullable=True),
|
||||
sa.Column('callback_payload', sa.JSON(), nullable=True),
|
||||
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column(
|
||||
'transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('paypear_payments')
|
||||
@@ -0,0 +1,47 @@
|
||||
"""create rollypay_payments table
|
||||
|
||||
Revision ID: 0059
|
||||
Revises: 0058
|
||||
Create Date: 2026-04-15
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0059'
|
||||
down_revision: Union[str, None] = '0058'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'rollypay_payments',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True),
|
||||
sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True),
|
||||
sa.Column('rollypay_payment_id', sa.String(128), unique=True, nullable=True, index=True),
|
||||
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
|
||||
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
|
||||
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||
sa.Column('payment_url', sa.Text(), nullable=True),
|
||||
sa.Column('payment_method', sa.String(32), nullable=True),
|
||||
sa.Column('metadata_json', sa.JSON(), nullable=True),
|
||||
sa.Column('callback_payload', sa.JSON(), nullable=True),
|
||||
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column(
|
||||
'transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('rollypay_payments')
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.45.2"
|
||||
version = "3.48.0"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
|
||||
@@ -564,7 +564,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
|
||||
@@ -1143,7 +1142,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remnawave-bedolaga-telegram-bot"
|
||||
version = "3.43.1"
|
||||
version = "3.46.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiogram" },
|
||||
|
||||
Reference in New Issue
Block a user