Compare commits

...

74 Commits

Author SHA1 Message Date
Egor 7ef7b1d107 Update Dockerfile 2025-09-19 05:10:54 +03:00
Egor 0cb9ab5704 Update docker-registry.yml 2025-09-19 05:10:45 +03:00
Egor 4d109b948a Update docker-hub.yml 2025-09-19 05:10:33 +03:00
Egor a2c0551349 Merge pull request #106 from Fr1ngg/dependabot/pip/python-dependencies-14836bb5a9
deps(deps): bump the python-dependencies group with 12 updates
2025-09-19 03:46:55 +03:00
Egor a66719b865 Merge pull request #105 from Fr1ngg/dependabot/docker/python-3.13-slim
docker(deps): bump python from 3.11-slim to 3.13-slim
2025-09-19 03:46:12 +03:00
Egor b201543014 Merge pull request #104 from Fr1ngg/dependabot/github_actions/github-actions-e8a2446fba
ci(deps): bump the github-actions group with 2 updates
2025-09-19 03:45:55 +03:00
Egor e269f31056 Update dependabot.yml 2025-09-19 03:44:57 +03:00
dependabot[bot] 0c1db66be6 deps(deps): bump the python-dependencies group with 12 updates
---
updated-dependencies:
- dependency-name: aiogram
  dependency-version: 3.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: aiohttp
  dependency-version: 3.12.15
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: asyncpg
  dependency-version: 0.30.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: sqlalchemy
  dependency-version: 2.0.43
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: alembic
  dependency-version: 1.16.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: aiosqlite
  dependency-version: 0.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic
  dependency-version: 2.11.9
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic-settings
  dependency-version: 2.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: python-dotenv
  dependency-version: 1.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: yookassa
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: apscheduler
  dependency-version: 3.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: python-dateutil
  dependency-version: 2.9.0.post0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-19 00:43:47 +00:00
dependabot[bot] 5e46507f04 docker(deps): bump python from 3.11-slim to 3.13-slim
Bumps python from 3.11-slim to 3.13-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.13-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-19 00:43:05 +00:00
dependabot[bot] f3616abec2 ci(deps): bump the github-actions group with 2 updates
Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [docker/build-push-action](https://github.com/docker/build-push-action).


Updates `actions/checkout` from 4 to 5
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

Updates `docker/build-push-action` from 5 to 6
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-19 00:42:59 +00:00
Egor 86ec46dbe7 Create dependabot.yml 2025-09-19 03:42:10 +03:00
Egor 863b5df0c2 Delete .github/workflows/dependabot.yml 2025-09-19 03:41:41 +03:00
Egor 929b3b8e95 Merge pull request #101 from Gy9vin/dev
Добавлена функция покупки подписки администратором с преобразованием …
2025-09-19 03:40:20 +03:00
Egor f79393c46e Create dependabot.yml 2025-09-19 03:38:52 +03:00
Egor 559dd1df71 Update docker-registry.yml 2025-09-19 03:30:37 +03:00
Egor cb959bdee7 Update docker-hub.yml 2025-09-19 03:30:14 +03:00
Egor ac1b0ec8b3 Update users.py 2025-09-19 03:25:06 +03:00
gy9vin 03856d30ec Добавлена функция покупки подписки администратором с преобразованием триала в безлимитную подписку 2025-09-18 23:50:01 +03:00
Egor cd98d150d6 Merge pull request #100 from Fr1ngg/dev
Починка покупки подписки
2025-09-18 16:29:07 +03:00
Egor 92814ae535 Update subscription.py 2025-09-18 16:22:22 +03:00
Egor 3268c2a509 Update user_utils.py 2025-09-18 16:20:06 +03:00
Egor a09b6c1431 Update subscription.py 2025-09-18 16:19:07 +03:00
Egor 728bd0450d Merge pull request #95 from Legacyyy777/DEV
№6
2025-09-18 03:13:43 +03:00
Egor 3df3fdbcfe Merge pull request #99 from Fr1ngg/dev
Dev
2025-09-18 03:13:00 +03:00
Egor 296756dac4 Merge pull request #96 from Gy9vin/dev
Пополнение баланса кнопками
2025-09-18 03:11:39 +03:00
Egor 7cb766f3d0 Merge pull request #98 from yazhog/main
Фикс отправки кнопок в рассылках, скрытие в детализации подписки нулевых значений за сервера/траффик
2025-09-18 03:10:49 +03:00
Egor 5e94b287b9 Delete .github/workflows/deploy.yml 2025-09-18 03:08:50 +03:00
yazhog e9eb8f17d3 Merge pull request #31 from yazhog/codex/remove-zero-cost-server-traffic-options-from-calculations
Hide zero-cost options from subscription pricing breakdown
2025-09-18 00:46:18 +03:00
yazhog dfbaf9de21 Filter zero-cost subscription pricing details 2025-09-18 00:37:02 +03:00
yazhog 4dcca0da4a Merge pull request #30 from yazhog/codex/replace-button-text-from-to
Rename referrals button text to Партнерка
2025-09-18 00:33:04 +03:00
yazhog ea85d4f62e Rename referrals button to partnerka 2025-09-18 00:30:01 +03:00
yazhog bef1a3ecd9 Merge pull request #29 from yazhog/codex/analyze-button-attachment-to-message-delivery
Make home button optional in broadcasts
2025-09-18 00:07:50 +03:00
yazhog 22be75508e Add optional broadcast buttons for connect, subscription, and support 2025-09-17 22:51:18 +03:00
yazhog dba8241c5c Make home button optional in broadcasts 2025-09-17 22:19:55 +03:00
gy9vin b3d0348821 2 кнопки баланса в ряду 2025-09-17 18:54:20 +03:00
gy9vin 3006a438e2 Merge branch 'dev' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot into dev 2025-09-17 18:34:24 +03:00
gy9vin d2f74481cd Добавление кнопок пополнения с суммой подписки 2025-09-17 18:31:21 +03:00
Legacyyy777 d29ad223a7 Добавлено уведомление пользователю о пополнении/списании баланса. Реализована отправка сообщений через Telegram-бота с информацией о транзакции и администраторе, выполнившем операцию. 2025-09-17 19:22:43 +05:00
Legacyyy777 c62ccd0790 Merge branch 'Fr1ngg:main' into DEV 2025-09-17 18:14:03 +05:00
Egor 8ff240cbe9 Add files via upload 2025-09-17 16:06:22 +03:00
Egor 4a5769d37e Create docker-hub.yml 2025-09-17 16:05:51 +03:00
Egor 7d27ee7937 Delete .github/workflows directory 2025-09-17 16:04:00 +03:00
Egor a927a6fa70 Create docker-registry.yml 2025-09-17 16:02:47 +03:00
Egor be4db68d00 Delete .github/ISSUE_TEMPLATE/bug_report.md 2025-09-17 16:01:36 +03:00
Egor 6e3807cf78 Delete .github/ISSUE_TEMPLATE/custom.md 2025-09-17 16:01:28 +03:00
Egor ff9adfc30f Delete .github/ISSUE_TEMPLATE/feature_request.md 2025-09-17 16:01:18 +03:00
Egor a6037b60eb Update yookassa_service.py 2025-09-17 15:58:27 +03:00
Egor a0b95cc24c Update payment_service.py 2025-09-17 15:57:27 +03:00
Egor a2536b576e Update inline.py 2025-09-17 15:55:39 +03:00
Egor 0e544040fc Update balance.py 2025-09-17 15:54:58 +03:00
Egor 940957fa98 Update config.py 2025-09-17 15:52:29 +03:00
Legacyyy777 2e6b808f6d Merge branch 'Fr1ngg:main' into DEV 2025-09-17 17:10:15 +05:00
Egor 9ff4734659 Merge pull request #93 from Fr1ngg/main
к
2025-09-17 15:09:21 +03:00
Egor 7a08f483b9 Merge pull request #91 from Fr1ngg/dev
Dev
2025-09-17 14:47:06 +03:00
Egor c2b10e969e Add files via upload 2025-09-17 14:36:29 +03:00
Egor 7bf9b10963 Update user_utils.py 2025-09-17 14:31:44 +03:00
Egor 6402cbef06 Update subscription_service.py 2025-09-17 14:28:42 +03:00
Legacyyy777 b8855bf73b Merge branch 'DEV' of https://github.com/Legacyyy777/remnawave-bedolaga-telegram-bot_777 into DEV 2025-09-17 16:10:19 +05:00
Legacyyy777 5453efadb5 Добавлен файл конфигурации для автоматического деплоя бота на VPS при пуше в ветку DEV. 2025-09-17 16:07:56 +05:00
Egor 8246405c31 Merge pull request #88 from Gy9vin/dev
Добавление СБП отдельной кнопкой для yookassa и указание кастомных сумм для yookasse
2025-09-17 14:01:58 +03:00
Egor 777f16f3c1 Delete Dockerfile 2025-09-17 13:53:01 +03:00
Egor 11860ff21a Delete .github/workflows/docker-registry.yml 2025-09-17 13:52:43 +03:00
Egor 0a6df56dd2 Delete .github/workflows/docker-hub.yml 2025-09-17 13:52:27 +03:00
Egor 9dd643ecab Режим удаления пользователей из панели Remnawave by @Legacyyy777
№5
2025-09-17 13:46:27 +03:00
Egor d8d01384b1 Delete .github/workflows/deploy.yml 2025-09-17 13:44:22 +03:00
Legacyyy777 d0b3bbd488 Update .env.example 2025-09-17 14:29:22 +05:00
Legacyyy777 e8c458f2c4 Добавлен новый параметр REMNAWAVE_USER_DELETE_MODE в конфигурацию и обновлен процесс удаления пользователя RemnaWave с учетом выбранного режима удаления или деактивации. Обработаны возможные ошибки при взаимодействии с Remnawave. 2025-09-17 14:25:37 +05:00
Legacyyy777 e324e50713 Изменен процесс удаления пользователя RemnaWave: добавлена попытка удаления пользователя из панели Remnawave с обработкой ошибок и резервным вариантом деактивации через SubscriptionService. 2025-09-17 14:04:55 +05:00
Legacyyy777 d506ba7317 Добавлен файл конфигурации для деплоя бота на VPS при пуше в ветку DEV 2025-09-17 13:46:37 +05:00
Legacyyy777 2c960813de restore deploy.yml for my fork deploy 2025-09-17 13:38:39 +05:00
gy9vin 1d98bc75b0 Fix gpg 2025-09-17 11:31:31 +03:00
gy9vin 19dddb3cc0 changing the minimum and maximum amount for yookassa 2025-09-17 11:26:25 +03:00
gy9vin 863dd2423d adding the SBP method for YooKassa 2025-09-17 10:48:58 +03:00
Egor 4cc5e92b83 Update backup_service.py 2025-09-17 03:49:21 +03:00
25 changed files with 1526 additions and 453 deletions
+15
View File
@@ -59,6 +59,11 @@ REMNAWAVE_SECRET_KEY=
# {telegram_id} — ID Telegram
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
# Режим удаления пользователей из панели RemnaWave
# delete - полностью удалить пользователя из панели
# disable - только деактивировать пользователя
REMNAWAVE_USER_DELETE_MODE=delete
# ========= ПОДПИСКИ =========
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
@@ -147,6 +152,9 @@ YOOKASSA_SECRET_KEY=
YOOKASSA_RETURN_URL=
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
# Включить оплату через СБП (Систему быстрых платежей)
YOOKASSA_SBP_ENABLED=false
# Настройки чеков для налоговой
YOOKASSA_VAT_CODE=1
# Коды НДС:
@@ -188,6 +196,13 @@ YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
# Лимиты сумм пополнения через YooKassa (в копейках)
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
# Быстрый выбор суммы пополнения через YooKassa
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
-38
View File
@@ -1,38 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
-10
View File
@@ -1,10 +0,0 @@
---
name: Custom issue template
about: Describe this issue template's purpose here.
title: ''
labels: ''
assignees: ''
---
-20
View File
@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+71
View File
@@ -0,0 +1,71 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Europe/Moscow"
open-pull-requests-limit: 10
reviewers:
- "Fr1ngg"
assignees:
- "Fr1ngg"
commit-message:
prefix: "deps"
include: "scope"
labels:
- "dependencies"
- "python"
allow:
- dependency-type: "all"
groups:
python-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "10:00"
timezone: "Europe/Moscow"
open-pull-requests-limit: 5
reviewers:
- "Fr1ngg"
assignees:
- "Fr1ngg"
commit-message:
prefix: "ci"
include: "scope"
labels:
- "dependencies"
- "github-actions"
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
day: "tuesday"
time: "09:00"
timezone: "Europe/Moscow"
open-pull-requests-limit: 3
reviewers:
- "Fr1ngg"
assignees:
- "Fr1ngg"
commit-message:
prefix: "docker"
include: "scope"
labels:
- "dependencies"
- "docker"
+6 -6
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.3.4-$(git rev-parse --short HEAD)"
VERSION="v2.3.5-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.3.4-dev-$(git rev-parse --short HEAD)"
VERSION="v2.3.5-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.3.4-pr-$(git rev-parse --short HEAD)"
VERSION="v2.3.5-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
@@ -68,11 +68,11 @@ jobs:
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
platforms: linux/amd64
push: ${{ steps.version.outputs.should_push }}
tags: ${{ steps.version.outputs.tags }}
build-args: |
+30 -43
View File
@@ -24,18 +24,15 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: |
network=host
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
@@ -50,31 +47,26 @@ jobs:
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Собираем релизную версию: $VERSION"
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.3.4"
echo "🚀 Собираем версию из main: $VERSION"
VERSION="v2.3.5-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.3.4-dev-$(git rev-parse --short HEAD)"
echo "🧪 Собираем dev версию: $VERSION"
VERSION="v2.3.5-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.3.4-pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
VERSION="v2.3.5-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Определяем, нужно ли пушить образ
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
if [[ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
echo "should_push=true" >> $GITHUB_OUTPUT
echo "✅ PR из того же репозитория - будем пушить"
else
echo "should_push=false" >> $GITHUB_OUTPUT
echo "⚠️ PR из внешнего форка - только build без push"
fi
echo "should_push=false" >> $GITHUB_OUTPUT
echo "⚠️ PR - only build without push"
else
echo "should_push=true" >> $GITHUB_OUTPUT
echo "✅ Push/Tag - будем пушить"
echo "✅ Push/Tag - will push"
fi
- name: Extract metadata
@@ -93,7 +85,7 @@ jobs:
type=raw,value=${{ steps.version.outputs.version }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
@@ -105,18 +97,12 @@ jobs:
VERSION=${{ steps.version.outputs.version }}
BUILD_DATE=${{ steps.version.outputs.build_date }}
VCS_REF=${{ steps.version.outputs.short_sha }}
cache-from: |
type=gha
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: |
type=gha,mode=max
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
build-contexts: |
alpine=docker-image://alpine:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate security report
uses: docker/scout-action@v1
if: github.event_name == 'pull_request' && steps.version.outputs.should_push == 'true'
if: github.event_name == 'pull_request'
with:
command: quickview,compare
image: ${{ steps.meta.outputs.tags }}
@@ -125,30 +111,31 @@ jobs:
only-severities: critical,high
write-comment: true
github-token: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
- name: Build Summary
if: steps.version.outputs.should_push == 'true'
run: |
echo "## 🚀 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Commit** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Build Date** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Registry** | \`${{ env.REGISTRY }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Образ** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY
echo "| **Image** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Branch** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Status** | ✅ Published |" >> $GITHUB_STEP_SUMMARY
- name: Build Summary (No Push)
if: steps.version.outputs.should_push == 'false'
run: |
echo "## 🔨 Docker Build Summary (Test Only)" >> $GITHUB_STEP_SUMMARY
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Статус** | ✅ Собран успешно (без публикации) |" >> $GITHUB_STEP_SUMMARY
echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Commit** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Build Date** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Status** | ✅ Built successfully (without publishing) |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "⚠️ **Примечание:** Образ собран но не опубликован, так как это PR из внешнего форка." >> $GITHUB_STEP_SUMMARY
echo "⚠️ **Note:** Image built but not published as this is a pull request." >> $GITHUB_STEP_SUMMARY
+3 -3
View File
@@ -1,4 +1,4 @@
FROM python:3.11-slim AS builder
FROM python:3.13-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
@@ -12,9 +12,9 @@ COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
FROM python:3.11-slim
FROM python:3.13-slim
ARG VERSION="v2.3.4"
ARG VERSION="v2.3.5"
ARG BUILD_DATE
ARG VCS_REF
+6 -3
View File
@@ -371,6 +371,9 @@ YOOKASSA_SECRET_KEY=
YOOKASSA_RETURN_URL=
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
# Включить оплату через СБП (Систему быстрых платежей)
YOOKASSA_SBP_ENABLED=false
# Настройки чеков для налоговой
YOOKASSA_VAT_CODE=1
# Коды НДС:
@@ -887,7 +890,7 @@ bedolaga_bot/
│ │ ├── 💳 cryptobot.py # CryptoBot операции
│ │ ├── 🌐 server_squad.py # Серверы и сквады
│ │ ├── 🎁 promocode.py # Промокоды
│ │ └── 👥 referral.py # Рефералы
│ │ └── 👥 referral.py # Партнерка
│ │
│ ├── 🔧 services/ # Бизнес-логика
│ │ ├── 👤 user_service.py # Сервис пользователей
@@ -896,7 +899,7 @@ bedolaga_bot/
│ │ ├── 🗄️ backup_service.py # Бекапы
│ │ ├── 🎁 promocode_service.py # Промокоды
│ │ ├── 🚧 maintenance_service.py # Промокоды
│ │ ├── 👥 referral_service.py # Рефералы
│ │ ├── 👥 referral_service.py # Партнерка
│ │ ├── 💬 admin_notification_service.py # Уведомления для администраторов в чаты
│ │ ├── 🔍 monitoring_service.py # Мониторинг
│ │ ├── ♻️ version_service.py # Проверка версий бота
@@ -1060,7 +1063,7 @@ your-domain.com {
3. **💰 Баланс** → "💰 Баланс" → пополни через Stars/Tribute/YooKassa
4. **🛒 Подписка** → "🛒 Купить подписку" → выбор тарифа → оплата
5. **📱 Управление** → "📋 Мои подписки" → конфигурация → получение ссылки
6. **👥 Рефералы** → "👥 Рефералы" → поделись ссылкой
6. **👥 Партнерка** → "👥 Партнерка" → поделись ссылкой
### ⚙️ **Для администраторов**
+11 -2
View File
@@ -43,6 +43,7 @@ class Settings(BaseSettings):
REMNAWAVE_PASSWORD: Optional[str] = None
REMNAWAVE_AUTH_TYPE: str = "api_key"
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable"
TRIAL_DURATION_DAYS: int = 3
TRIAL_TRAFFIC_LIMIT_GB: int = 10
@@ -61,7 +62,6 @@ class Settings(BaseSettings):
NOTIFICATION_CACHE_HOURS: int = 24
BASE_SUBSCRIPTION_PRICE: int = 50000
AVAILABLE_SUBSCRIPTION_PERIODS: str = "14,30,60,90,180,360"
AVAILABLE_RENEWAL_PERIODS: str = "30,90,180"
PRICE_14_DAYS: int = 50000
@@ -124,12 +124,16 @@ class Settings(BaseSettings):
YOOKASSA_SECRET_KEY: Optional[str] = None
YOOKASSA_RETURN_URL: Optional[str] = None
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = None
YOOKASSA_VAT_CODE: int = 1
YOOKASSA_VAT_CODE: int = 1
YOOKASSA_SBP_ENABLED: bool = False
YOOKASSA_PAYMENT_MODE: str = "full_payment"
YOOKASSA_PAYMENT_SUBJECT: str = "service"
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
YOOKASSA_WEBHOOK_PORT: int = 8082
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
@@ -259,6 +263,11 @@ class Settings(BaseSettings):
"password": self.REMNAWAVE_PASSWORD,
"auth_type": self.REMNAWAVE_AUTH_TYPE
}
def get_remnawave_user_delete_mode(self) -> str:
"""Возвращает режим удаления пользователей: 'delete' или 'disable'"""
mode = self.REMNAWAVE_USER_DELETE_MODE.lower().strip()
return mode if mode in ["delete", "disable"] else "delete"
def format_remnawave_user_description(
self,
+16 -8
View File
@@ -442,6 +442,8 @@ async def add_subscription_servers(
server_squad_ids: List[int],
paid_prices: List[int] = None
) -> Subscription:
await db.refresh(subscription)
if paid_prices is None:
months_remaining = get_remaining_months(subscription.end_date)
paid_prices = []
@@ -458,7 +460,7 @@ async def add_subscription_servers(
for i, server_id in enumerate(server_squad_ids):
subscription_server = SubscriptionServer(
subscription_id=subscription.id,
subscription_id=subscription.id,
server_squad_id=server_id,
paid_price_kopeks=paid_prices[i] if i < len(paid_prices) else 0
)
@@ -467,7 +469,7 @@ async def add_subscription_servers(
await db.commit()
await db.refresh(subscription)
logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}")
logger.info(f"🌐 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}")
return subscription
async def get_server_monthly_price(
@@ -533,9 +535,12 @@ async def calculate_subscription_total_cost(
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
logger.info(f" Базовый период: {base_price/100}")
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}")
logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}")
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}")
if total_traffic_price > 0:
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}")
if total_servers_price > 0:
logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}")
if total_devices_price > 0:
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}")
logger.info(f" ИТОГО: {total_cost/100}")
return total_cost, details
@@ -646,9 +651,12 @@ async def get_subscription_renewal_cost(
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):")
logger.info(f" 📅 Период: {base_price/100}")
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}")
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}")
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}")
if total_servers_cost > 0:
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}")
if total_traffic_cost > 0:
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}")
if total_devices_cost > 0:
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}")
logger.info(f" 💎 ИТОГО: {total_cost/100}")
return total_cost
+93 -132
View File
@@ -14,7 +14,9 @@ from app.keyboards.admin import (
get_admin_messages_keyboard, get_broadcast_target_keyboard,
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
get_admin_pagination_keyboard, get_broadcast_media_keyboard,
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media,
BROADCAST_BUTTONS, BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
BROADCAST_BUTTON_LABELS
)
from app.localization.texts import get_texts
from app.database.crud.user import get_users_list
@@ -23,69 +25,42 @@ from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
BUTTON_CONFIG = BROADCAST_BUTTONS
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
BUTTON_LABELS = BROADCAST_BUTTON_LABELS
def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup:
return types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="btn_balance"),
types.InlineKeyboardButton(text="🤝 Рефералы", callback_data="btn_referrals")
],
[
types.InlineKeyboardButton(text="🎫 Промокод", callback_data="btn_promocode")
],
[
types.InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
]
])
return get_updated_message_buttons_selector_keyboard(list(DEFAULT_SELECTED_BUTTONS), language)
def get_updated_message_buttons_selector_keyboard(selected_buttons: list, language: str = "ru") -> types.InlineKeyboardMarkup:
balance_text = "✅ Пополнить баланс" if "balance" in selected_buttons else "💰 Пополнить баланс"
referrals_text = "✅ Рефералы" if "referrals" in selected_buttons else "🤝 Рефералы"
promocode_text = "✅ Промокод" if "promocode" in selected_buttons else "🎫 Промокод"
return types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text=balance_text, callback_data="btn_balance"),
types.InlineKeyboardButton(text=referrals_text, callback_data="btn_referrals")
],
[
types.InlineKeyboardButton(text=promocode_text, callback_data="btn_promocode")
],
[
types.InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
]
])
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
def create_broadcast_keyboard(selected_buttons: list) -> types.InlineKeyboardMarkup:
keyboard = []
button_row = []
if "balance" in selected_buttons:
button_row.append(types.InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup"))
if "referrals" in selected_buttons:
button_row.append(types.InlineKeyboardButton(text="🤝 Рефералы", callback_data="menu_referrals"))
if "promocode" in selected_buttons:
button_row.append(types.InlineKeyboardButton(text="🎫 Промокод", callback_data="menu_promocode"))
if len(button_row) > 2:
keyboard.append(button_row[:2])
if len(button_row) > 2:
keyboard.append(button_row[2:])
elif button_row:
keyboard.append(button_row)
keyboard.append([types.InlineKeyboardButton(text="🏠 На главную", callback_data="back_to_menu")])
def create_broadcast_keyboard(selected_buttons: list) -> Optional[types.InlineKeyboardMarkup]:
selected_buttons = selected_buttons or []
keyboard: list[list[types.InlineKeyboardButton]] = []
for row in BUTTON_ROWS:
row_buttons: list[types.InlineKeyboardButton] = []
for button_key in row:
if button_key not in selected_buttons:
continue
button_config = BUTTON_CONFIG[button_key]
row_buttons.append(
types.InlineKeyboardButton(
text=button_config["text"],
callback_data=button_config["callback"]
)
)
if row_buttons:
keyboard.append(row_buttons)
if not keyboard:
return None
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -495,7 +470,11 @@ async def show_button_selector_callback(
):
data = await state.get_data()
has_media = data.get('has_media', False)
selected_buttons = data.get('selected_buttons', [])
selected_buttons = data.get('selected_buttons')
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
await state.update_data(selected_buttons=selected_buttons)
media_info = ""
if has_media:
@@ -507,11 +486,14 @@ async def show_button_selector_callback(
Выберите кнопки, которые будут добавлены к сообщению рассылки:
💰 <b>Пополнить баланс</b> - откроет методы пополнения
🤝 <b>Рефералы</b> - откроет реферальную программу
🎫 <b>Промокод</b> - откроет форму ввода промокода
💰 <b>Пополнить баланс</b> откроет методы пополнения
🤝 <b>Партнерка</b> откроет реферальную программу
🎫 <b>Промокод</b> откроет форму ввода промокода
🔗 <b>Подключиться</b> — поможет подключить приложение
📱 <b>Подписка</b> — покажет состояние подписки
🛠️ <b>Техподдержка</b> — свяжет с поддержкой
Кнопка "🏠 На главную" добавляется автоматически внизу.{media_info}
🏠 <b>Кнопка "На главную"</b> включена по умолчанию, но вы можете отключить её при необходимости.{media_info}
Выберите нужные кнопки и нажмите "Продолжить":
"""
@@ -535,28 +517,38 @@ async def show_button_selector(
db_user: User,
state: FSMContext
):
data = await state.get_data()
selected_buttons = data.get('selected_buttons')
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
await state.update_data(selected_buttons=selected_buttons)
has_media = data.get('has_media', False)
text = """
📘 <b>Выбор дополнительных кнопок</b>
Выберите кнопки, которые будут добавлены к сообщению рассылки:
💰 <b>Пополнить баланс</b> - откроет методы пополнения
🤝 <b>Рефералы</b> - откроет реферальную программу
🎫 <b>Промокод</b> - откроет форму ввода промокода
💰 <b>Пополнить баланс</b> откроет методы пополнения
🤝 <b>Партнерка</b> откроет реферальную программу
🎫 <b>Промокод</b> откроет форму ввода промокода
🔗 <b>Подключиться</b> — поможет подключить приложение
📱 <b>Подписка</b> — покажет состояние подписки
🛠️ <b>Техподдержка</b> — свяжет с поддержкой
Кнопка "🏠 На главную" добавляется автоматически внизу.
🏠 <b>Кнопка "На главную"</b> включена по умолчанию, но вы можете отключить её при необходимости.
Выберите нужные кнопки и нажмите "Продолжить":
"""
data = await state.get_data()
if 'selected_buttons' not in data:
data['selected_buttons'] = []
await state.set_data(data)
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
selected_buttons, has_media, db_user.language
)
await message.answer(
text,
reply_markup=get_message_buttons_selector_keyboard(db_user.language),
reply_markup=keyboard,
parse_mode="HTML"
)
@@ -570,18 +562,24 @@ async def toggle_button_selection(
):
button_type = callback.data.replace('btn_', '')
data = await state.get_data()
selected_buttons = data.get('selected_buttons', [])
selected_buttons = data.get('selected_buttons')
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
else:
selected_buttons = list(selected_buttons)
if button_type in selected_buttons:
selected_buttons.remove(button_type)
else:
selected_buttons.append(button_type)
data['selected_buttons'] = selected_buttons
await state.set_data(data)
keyboard = get_updated_message_buttons_selector_keyboard(selected_buttons, db_user.language)
await state.update_data(selected_buttons=selected_buttons)
has_media = data.get('has_media', False)
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
selected_buttons, has_media, db_user.language
)
await callback.message.edit_reply_markup(reply_markup=keyboard)
await callback.answer()
@@ -597,7 +595,10 @@ async def confirm_button_selection(
data = await state.get_data()
target = data.get('broadcast_target')
message_text = data.get('broadcast_message')
selected_buttons = data.get('selected_buttons', [])
selected_buttons = data.get('selected_buttons')
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
await state.update_data(selected_buttons=selected_buttons)
has_media = data.get('has_media', False)
media_type = data.get('media_type')
@@ -613,18 +614,12 @@ async def confirm_button_selection(
}
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type_names.get(media_type, media_type)}"
buttons_info = ""
if selected_buttons:
buttons_list = []
if "balance" in selected_buttons:
buttons_list.append("💰 Пополнить баланс")
if "referrals" in selected_buttons:
buttons_list.append("🤝 Рефералы")
if "promocode" in selected_buttons:
buttons_list.append("🎫 Промокод")
buttons_info = f"\n📘 <b>Дополнительные кнопки:</b> {', '.join(buttons_list)}"
buttons_info += "\n🏠 <b>Основная кнопка:</b> На главную"
ordered_keys = [button_key for row in BUTTON_ROWS for button_key in row]
selected_names = [BUTTON_LABELS[key] for key in ordered_keys if key in selected_buttons]
if selected_names:
buttons_info = f"\n📘 <b>Кнопки:</b> {', '.join(selected_names)}"
else:
buttons_info = "\n📘 <b>Кнопки:</b> отсутствуют"
preview_text = f"""
📨 <b>Предварительный просмотр рассылки</b>
@@ -685,42 +680,6 @@ async def confirm_button_selection(
)
await callback.answer()
@admin_required
@error_handler
async def show_button_selector_callback(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
text = """
📘 <b>Выбор дополнительных кнопок</b>
Выберите кнопки, которые будут добавлены к сообщению рассылки:
💰 <b>Пополнить баланс</b> - откроет методы пополнения
🤝 <b>Рефералы</b> - откроет реферальную программу
🎫 <b>Промокод</b> - откроет форму ввода промокода
Кнопка "🏠 На главную" добавляется автоматически внизу.
Выберите нужные кнопки и нажмите "Продолжить":
"""
data = await state.get_data()
selected_buttons = data.get('selected_buttons', [])
keyboard = get_updated_message_buttons_selector_keyboard(selected_buttons, db_user.language)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def confirm_broadcast(
@@ -732,7 +691,9 @@ async def confirm_broadcast(
data = await state.get_data()
target = data.get('broadcast_target')
message_text = data.get('broadcast_message')
selected_buttons = data.get('selected_buttons', [])
selected_buttons = data.get('selected_buttons')
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
has_media = data.get('has_media', False)
media_type = data.get('media_type')
media_file_id = data.get('media_file_id')
@@ -771,7 +732,7 @@ async def confirm_broadcast(
sent_count = 0
failed_count = 0
broadcast_keyboard = create_broadcast_keyboard(selected_buttons) if selected_buttons else None
broadcast_keyboard = create_broadcast_keyboard(selected_buttons)
for user in users:
try:
+311 -2
View File
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import AdminStates
from app.database.models import User, UserStatus, Subscription
from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType
from app.database.crud.user import get_user_by_id
from app.keyboards.admin import (
get_admin_users_keyboard, get_user_management_keyboard,
@@ -18,6 +18,7 @@ from app.services.user_service import UserService
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime, format_time_ago
from app.services.remnawave_service import RemnaWaveService
from app.external.remnawave_api import TrafficLimitStrategy
from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_uuid, get_server_squad_by_id
logger = logging.getLogger(__name__)
@@ -324,6 +325,10 @@ async def show_user_subscription(
types.InlineKeyboardButton(
text="🔄 Тип подписки",
callback_data=f"admin_sub_change_type_{user_id}"
),
types.InlineKeyboardButton(
text="💳 Купить подписку",
callback_data=f"admin_sub_buy_{user_id}"
)
]
]
@@ -685,7 +690,8 @@ async def process_balance_edit(
description = f"Списание администратором: {int(amount_rubles)}"
success = await user_service.update_user_balance(
db, user_id, amount_kopeks, description, db_user.id
db, user_id, amount_kopeks, description, db_user.id,
bot=message.bot, admin_name=db_user.full_name
)
if success:
@@ -2326,6 +2332,289 @@ async def change_subscription_type(
)
await callback.answer()
@admin_required
@error_handler
async def admin_buy_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
user_service = UserService()
profile = await user_service.get_user_profile(db, user_id)
if not profile:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
target_user = profile["user"]
subscription = profile["subscription"]
if not subscription:
await callback.answer("❌ У пользователя нет подписки", show_alert=True)
return
available_periods = settings.get_available_subscription_periods()
period_buttons = []
for period in available_periods:
price_attr = f"PRICE_{period}_DAYS"
if hasattr(settings, price_attr):
price_kopeks = getattr(settings, price_attr)
price_rubles = price_kopeks // 100
period_buttons.append([
types.InlineKeyboardButton(
text=f"{period} дней ({price_rubles} ₽)",
callback_data=f"admin_buy_sub_confirm_{user_id}_{period}_{price_kopeks}"
)
])
period_buttons.append([
types.InlineKeyboardButton(
text="❌ Отмена",
callback_data=f"admin_user_subscription_{user_id}"
)
])
text = f"💳 <b>Покупка подписки для пользователя</b>\n\n"
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
text += "Выберите период подписки:\n"
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=period_buttons)
)
await callback.answer()
@admin_required
@error_handler
async def admin_buy_subscription_confirm(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
parts = callback.data.split('_')
user_id = int(parts[4])
period_days = int(parts[5])
price_kopeks = int(parts[6])
user_service = UserService()
profile = await user_service.get_user_profile(db, user_id)
if not profile:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
target_user = profile["user"]
subscription = profile["subscription"]
if target_user.balance_kopeks < price_kopeks:
missing_kopeks = price_kopeks - target_user.balance_kopeks
await callback.message.edit_text(
f"❌ Недостаточно средств на балансе пользователя\n\n"
f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n"
f"💳 Стоимость подписки: {settings.format_price(price_kopeks)}\n"
f"📉 Не хватает: {settings.format_price(missing_kopeks)}\n\n"
f"Пополните баланс пользователя перед покупкой.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="⬅️ Назад к подписке",
callback_data=f"admin_user_subscription_{user_id}"
)]
])
)
await callback.answer()
return
price_rubles = price_kopeks // 100
text = f"💳 <b>Подтверждение покупки подписки</b>\n\n"
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
text += f"📅 Период подписки: {period_days} дней\n"
text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
text += "Вы уверены, что хотите купить подписку для этого пользователя?"
keyboard = [
[
types.InlineKeyboardButton(
text="✅ Подтвердить",
callback_data=f"admin_buy_sub_execute_{user_id}_{period_days}_{price_kopeks}"
)
],
[
types.InlineKeyboardButton(
text="❌ Отмена",
callback_data=f"admin_sub_buy_{user_id}"
)
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def admin_buy_subscription_execute(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
parts = callback.data.split('_')
user_id = int(parts[4])
period_days = int(parts[5])
price_kopeks = int(parts[6])
user_service = UserService()
profile = await user_service.get_user_profile(db, user_id)
if not profile:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
target_user = profile["user"]
subscription = profile["subscription"]
if target_user.balance_kopeks < price_kopeks:
await callback.answer("❌ Недостаточно средств на балансе пользователя", show_alert=True)
return
try:
from app.database.crud.user import subtract_user_balance
success = await subtract_user_balance(
db, target_user, price_kopeks,
f"Покупка подписки на {period_days} дней (администратор)"
)
if not success:
await callback.answer("❌ Ошибка списания средств", show_alert=True)
return
if subscription:
current_time = datetime.utcnow()
if subscription.end_date <= current_time:
subscription.start_date = current_time
subscription.end_date = current_time + timedelta(days=period_days)
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = current_time
if subscription.is_trial or not subscription.is_active:
subscription.is_trial = False
if subscription.traffic_limit_gb != 0:
subscription.traffic_limit_gb = 0
subscription.device_limit = settings.DEFAULT_DEVICE_LIMIT
if subscription.is_trial:
subscription.traffic_used_gb = 0.0
await db.commit()
await db.refresh(subscription)
from app.database.crud.transaction import create_transaction
transaction = await create_transaction(
db=db,
user_id=target_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price_kopeks,
description=f"Продление подписки на {period_days} дней (администратор)"
)
try:
from app.services.remnawave_service import RemnaWaveService
from app.external.remnawave_api import UserStatus, TrafficLimitStrategy
remnawave_service = RemnaWaveService()
if target_user.remnawave_uuid:
async with remnawave_service.api as api:
remnawave_user = await api.update_user(
uuid=target_user.remnawave_uuid,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=target_user.full_name,
username=target_user.username,
telegram_id=target_user.telegram_id
),
active_internal_squads=subscription.connected_squads
)
else:
username = f"user_{target_user.telegram_id}"
async with remnawave_service.api as api:
remnawave_user = await api.create_user(
username=username,
expire_at=subscription.end_date,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
telegram_id=target_user.telegram_id,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=target_user.full_name,
username=target_user.username,
telegram_id=target_user.telegram_id
),
active_internal_squads=subscription.connected_squads
)
if remnawave_user and hasattr(remnawave_user, 'uuid'):
target_user.remnawave_uuid = remnawave_user.uuid
await db.commit()
if remnawave_user:
logger.info(f"Пользователь {target_user.telegram_id} успешно обновлен в RemnaWave")
else:
logger.error(f"Ошибка обновления пользователя {target_user.telegram_id} в RemnaWave")
except Exception as e:
logger.error(f"Ошибка работы с RemnaWave для пользователя {target_user.telegram_id}: {e}")
message = f"✅ Подписка пользователя продлена на {period_days} дней"
else:
message = "❌ Ошибка: у пользователя нет существующей подписки"
await callback.message.edit_text(
f"{message}\n\n"
f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
f"💰 Списано: {settings.format_price(price_kopeks)}\n"
f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="⬅️ Назад к подписке",
callback_data=f"admin_user_subscription_{user_id}"
)]
])
)
try:
if callback.bot:
await callback.bot.send_message(
chat_id=target_user.telegram_id,
text=f"💳 <b>Администратор продлил вашу подписку</b>\n\n"
f"📅 Подписка продлена на {period_days} дней\n"
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n"
f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}",
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления пользователю {target_user.telegram_id}: {e}")
await callback.answer()
except Exception as e:
logger.error(f"Ошибка покупки подписки администратором: {e}")
await callback.answer("❌ Ошибка при покупке подписки", show_alert=True)
await db.rollback()
@admin_required
@error_handler
@@ -2632,3 +2921,23 @@ def register_handlers(dp: Dispatcher):
change_subscription_type_confirm,
F.data.startswith("admin_sub_type_")
)
# Регистрация обработчика покупки подписки администратором
dp.callback_query.register(
admin_buy_subscription,
F.data.startswith("admin_sub_buy_")
)
# Регистрация дополнительных обработчиков для покупки подписки
dp.callback_query.register(
admin_buy_subscription_confirm,
F.data.startswith("admin_buy_sub_confirm_")
)
dp.callback_query.register(
admin_buy_subscription_execute,
F.data.startswith("admin_buy_sub_execute_")
)
+327 -8
View File
@@ -25,6 +25,49 @@ logger = logging.getLogger(__name__)
TRANSACTIONS_PER_PAGE = 10
def get_quick_amount_buttons(language: str) -> list:
"""
Генерирует кнопки быстрого выбора суммы пополнения на основе
AVAILABLE_SUBSCRIPTION_PERIODS и PRICE_*_DAYS
"""
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
return []
buttons = []
periods = settings.get_available_subscription_periods()
# Ограничиваем до 6 кнопок (3 ряда по 2 кнопки)
periods = periods[:6]
for period in periods:
# Получаем цену из настроек
price_attr = f"PRICE_{period}_DAYS"
if hasattr(settings, price_attr):
price_kopeks = getattr(settings, price_attr)
price_rubles = price_kopeks // 100
# Создаем callback_data для каждой кнопки
callback_data = f"quick_amount_{price_kopeks}"
# Добавляем кнопку
buttons.append(
types.InlineKeyboardButton(
text=f"{price_rubles} ₽ ({period} дней)",
callback_data=callback_data
)
)
# Разбиваем кнопки на ряды (по 2 в ряд)
keyboard_rows = []
for i in range(0, len(buttons), 2):
keyboard_rows.append(buttons[i:i + 2])
return keyboard_rows
@error_handler
@error_handler
async def show_balance_menu(
callback: types.CallbackQuery,
@@ -210,10 +253,35 @@ async def start_yookassa_payment(
await callback.answer("❌ Оплата картой через YooKassa временно недоступна", show_alert=True)
return
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
message_text = (
f"💳 <b>Оплата банковской картой</b>\n\n"
f"Выберите сумму пополнения или введите вручную сумму "
f"от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
)
else:
message_text = (
f"💳 <b>Оплата банковской картой</b>\n\n"
f"Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
)
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
"💳 <b>Оплата банковской картой</b>\n\n"
"Введите сумму для пополнения от 100 до 50,000 рублей:",
reply_markup=get_back_keyboard(db_user.language),
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
@@ -222,6 +290,55 @@ async def start_yookassa_payment(
await callback.answer()
@error_handler
async def start_yookassa_sbp_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
await callback.answer("❌ Оплата через СБП временно недоступна", show_alert=True)
return
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
message_text = (
f"🏦 <b>Оплата через СБП</b>\n\n"
f"Выберите сумму пополнения или введите вручную сумму "
f"от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
)
else:
message_text = (
f"🏦 <b>Оплата через СБП</b>\n\n"
f"Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
)
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="yookassa_sbp")
await callback.answer()
@error_handler
async def start_tribute_payment(
callback: types.CallbackQuery,
@@ -336,12 +453,27 @@ async def process_topup_amount(
data = await state.get_data()
payment_method = data.get("payment_method", "stars")
if payment_method in ["yookassa", "yookassa_sbp"]:
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f}")
return
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f}".replace(',', ' '))
return
if payment_method == "stars":
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
elif payment_method == "yookassa":
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "yookassa_sbp":
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "cryptobot":
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
@@ -419,8 +551,14 @@ async def process_yookassa_payment_amount(
await message.answer("❌ Оплата через YooKassa временно недоступна")
return
if amount_kopeks < 10000:
await message.answer("❌ Минимальная сумма для оплаты картой: 100 ₽")
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты картой: {min_rubles:.0f}")
return
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Максимальная сумма для оплаты картой: {max_rubles:,.0f}".replace(',', ' '))
return
try:
@@ -484,6 +622,91 @@ async def process_yookassa_payment_amount(
await state.clear()
@error_handler
async def process_yookassa_sbp_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
await message.answer("❌ Оплата через СБП временно недоступна")
return
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты через СБП: {min_rubles:.0f}")
return
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Максимальная сумма для оплаты через СБП: {max_rubles:,.0f}".replace(',', ' '))
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_yookassa_sbp_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
receipt_email=None,
receipt_phone=None,
metadata={
"user_telegram_id": str(db_user.telegram_id),
"user_username": db_user.username or "",
"purpose": "balance_topup_sbp"
}
)
if not payment_result:
await message.answer("❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
return
confirmation_url = payment_result.get("confirmation_url")
if not confirmation_url:
await message.answer("❌ Ошибка получения ссылки для оплаты через СБП. Обратитесь в поддержку.")
await state.clear()
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🏦 Оплатить через СБП", url=confirmation_url)],
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
f"🏦 <b>Оплата через СБП</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
f"📱 <b>Инструкция:</b>\n"
f"1. Нажмите кнопку 'Оплатить через СБП'\n"
f"2. Вас перенаправит в приложение вашего банка\n"
f"3. Подтвердите платеж через СБП\n"
f"4. Деньги поступят на баланс автоматически\n\n"
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем СБП от всех банков-участников\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
except Exception as e:
logger.error(f"Ошибка создания YooKassa СБП платежа: {e}")
await message.answer("❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
@error_handler
async def check_yookassa_payment_status(
callback: types.CallbackQuery,
@@ -602,7 +825,6 @@ async def process_cryptobot_payment_amount(
return
try:
# Получаем курс из состояния или запрашиваем заново
data = await state.get_data()
current_rate = data.get('current_rate')
@@ -610,10 +832,8 @@ async def process_cryptobot_payment_amount(
from app.utils.currency_converter import currency_converter
current_rate = await currency_converter.get_usd_to_rub_rate()
# Конвертируем рубли в доллары
amount_usd = amount_rubles / current_rate
# Округляем до 2 знаков после запятой
amount_usd = round(amount_usd, 2)
if amount_usd < 1:
@@ -736,6 +956,89 @@ async def check_cryptobot_payment_status(
@error_handler
async def handle_sbp_payment(
callback: types.CallbackQuery,
db: AsyncSession
):
try:
local_payment_id = int(callback.data.split('_')[-1])
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
if not payment:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
import json
metadata = json.loads(payment.metadata_json) if payment.metadata_json else {}
confirmation_token = metadata.get("confirmation_token")
if not confirmation_token:
await callback.answer("❌ Токен подтверждения не найден", show_alert=True)
return
await callback.message.answer(
f"Для оплаты через СБП откройте приложение вашего банка и подтвердите платеж.\\n\\n"
f"Если у вас не открылось банковское приложение автоматически, вы можете:\\n"
f"1. Скопировать этот токен: <code>{confirmation_token}</code>\\n"
f"2. Открыть приложение вашего банка\\n"
f"3. Найти функцию оплаты по токену\\n"
f"4. Вставить токен и подтвердить платеж",
parse_mode="HTML"
)
await callback.answer("Информация об оплате отправлена", show_alert=True)
except Exception as e:
logger.error(f"Ошибка обработки embedded платежа СБП: {e}")
await callback.answer("❌ Ошибка обработки платежа", show_alert=True)
@error_handler
async def handle_quick_amount_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""
Обработчик выбора суммы через кнопки быстрого выбора
"""
# Извлекаем сумму из callback_data
try:
amount_kopeks = int(callback.data.split('_')[-1])
amount_rubles = amount_kopeks / 100
# Получаем метод оплаты из состояния
data = await state.get_data()
payment_method = data.get("payment_method", "yookassa")
# Проверяем, какой метод оплаты был выбран и вызываем соответствующий обработчик
if payment_method == "yookassa":
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "yookassa_sbp":
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
else:
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
return
except ValueError:
await callback.answer("❌ Ошибка обработки суммы", show_alert=True)
except Exception as e:
logger.error(f"Ошибка обработки быстрого выбора суммы: {e}")
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
@@ -768,6 +1071,16 @@ def register_handlers(dp: Dispatcher):
F.data == "topup_yookassa"
)
dp.callback_query.register(
start_yookassa_sbp_payment,
F.data == "topup_yookassa_sbp"
)
dp.callback_query.register(
check_yookassa_payment_status,
F.data.startswith("check_yookassa_")
)
dp.callback_query.register(
start_tribute_payment,
F.data == "topup_tribute"
@@ -802,3 +1115,9 @@ def register_handlers(dp: Dispatcher):
handle_payment_methods_unavailable,
F.data == "payment_methods_unavailable"
)
# Регистрируем обработчик для кнопок быстрого выбора суммы
dp.callback_query.register(
handle_quick_amount_selection,
F.data.startswith("quick_amount_")
)
+43 -25
View File
@@ -324,9 +324,12 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
logger.info(f"📊 Месячная стоимость конфигурации подписки {subscription.id}:")
logger.info(f" 📅 Базовый тариф (30 дней): {base_cost/100}")
logger.info(f" 🌍 Серверы: {servers_cost/100}")
logger.info(f" 📊 Трафик: {traffic_cost/100}")
logger.info(f" 📱 Устройства: {devices_cost/100}")
if servers_cost > 0:
logger.info(f" 🌍 Серверы: {servers_cost/100}")
if traffic_cost > 0:
logger.info(f" 📊 Трафик: {traffic_cost/100}")
if devices_cost > 0:
logger.info(f" 📱 Устройства: {devices_cost/100}")
logger.info(f" 💎 ИТОГО: {total_cost/100}")
return total_cost
@@ -1554,9 +1557,12 @@ async def confirm_extend_subscription(
logger.info(f"💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):")
logger.info(f" 📅 Период {days} дней: {base_price/100}")
logger.info(f" 🌐 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}")
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}")
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}")
if total_servers_price > 0:
logger.info(f" 🌐 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}")
if total_devices_price > 0:
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}")
if total_traffic_price > 0:
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}")
logger.info(f" 💎 ИТОГО: {price/100}")
except Exception as e:
@@ -2109,24 +2115,33 @@ async def devices_continue(
else:
traffic_display = f"{data['traffic_gb']} ГБ"
summary_text = f"""
📋 <b>Сводка заказа</b>
details_lines = [f"- Базовый период: {texts.format_price(base_price)}"]
if total_traffic_price > 0:
details_lines.append(
f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}"
)
if total_countries_price > 0:
details_lines.append(
f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}"
)
if total_devices_price > 0:
details_lines.append(
f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}"
)
📅 <b>Период:</b> {period_display}
📊 <b>Трафик:</b> {traffic_display}
🌍 <b>Страны:</b> {", ".join(selected_countries_names)}
📱 <b>Устройства:</b> {data['devices']}
details_text = "\n".join(details_lines)
💰 <b>Детализация стоимости:</b>
- Базовый период: {texts.format_price(base_price)}
- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}
- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}
- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}
💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}
Подтверждаете покупку?
"""
summary_text = (
"📋 <b>Сводка заказа</b>\n\n"
f"📅 <b>Период:</b> {period_display}\n"
f"📊 <b>Трафик:</b> {traffic_display}\n"
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}\n"
f"📱 <b>Устройства:</b> {data['devices']}\n\n"
"💰 <b>Детализация стоимости:</b>\n"
f"{details_text}\n\n"
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}\n\n"
"Подтверждаете покупку?"
)
await callback.message.edit_text(
summary_text,
@@ -2192,9 +2207,12 @@ async def confirm_purchase(
logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):")
logger.info(f" Период: {base_price/100}")
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}")
logger.info(f" Серверы: {countries_price_per_month/100}₽/мес × {months_in_period} = {total_countries_price/100}")
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}")
if total_traffic_price > 0:
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}")
if total_countries_price > 0:
logger.info(f" Серверы: {countries_price_per_month/100}₽/мес × {months_in_period} = {total_countries_price/100}")
if total_devices_price > 0:
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}")
logger.info(f" ИТОГО: {final_price/100}")
if db_user.balance_kopeks < final_price:
+48 -32
View File
@@ -225,7 +225,7 @@ def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
],
[
InlineKeyboardButton(text="💰 Доходы", callback_data="admin_stats_revenue"),
InlineKeyboardButton(text="🤝 Рефералы", callback_data="admin_stats_referrals")
InlineKeyboardButton(text="🤝 Партнерка", callback_data="admin_stats_referrals")
],
[
InlineKeyboardButton(text="📊 Общая сводка", callback_data="admin_stats_summary")
@@ -913,22 +913,30 @@ def get_welcome_text_keyboard(language: str = "ru", is_enabled: bool = True) ->
return InlineKeyboardMarkup(inline_keyboard=keyboard)
DEFAULT_BROADCAST_BUTTONS = ("home",)
BROADCAST_BUTTONS = {
"balance": {"text": "💰 Пополнить баланс", "callback": "balance_topup"},
"referrals": {"text": "🤝 Партнерка", "callback": "menu_referrals"},
"promocode": {"text": "🎫 Промокод", "callback": "menu_promocode"},
"connect": {"text": "🔗 Подключиться", "callback": "subscription_connect"},
"subscription": {"text": "📱 Подписка", "callback": "menu_subscription"},
"support": {"text": "🛠️ Техподдержка", "callback": "menu_support"},
"home": {"text": "🏠 На главную", "callback": "back_to_menu"},
}
BROADCAST_BUTTON_ROWS: tuple[tuple[str, ...], ...] = (
("balance", "referrals"),
("promocode", "connect"),
("subscription", "support"),
("home",),
)
BROADCAST_BUTTON_LABELS = {key: value["text"] for key, value in BROADCAST_BUTTONS.items()}
def get_message_buttons_selector_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="btn_balance"),
InlineKeyboardButton(text="🤝 Рефералы", callback_data="btn_referrals")
],
[
InlineKeyboardButton(text="🎫 Промокод", callback_data="btn_promocode")
],
[
InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
],
[
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
]
])
return get_updated_message_buttons_selector_keyboard_with_media(list(DEFAULT_BROADCAST_BUTTONS), False, language)
def get_broadcast_media_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
@@ -958,25 +966,33 @@ def get_media_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_updated_message_buttons_selector_keyboard_with_media(selected_buttons: list, has_media: bool = False, language: str = "ru") -> InlineKeyboardMarkup:
balance_text = "✅ Пополнить баланс" if "balance" in selected_buttons else "💰 Пополнить баланс"
referrals_text = "✅ Рефералы" if "referrals" in selected_buttons else "🤝 Рефералы"
promocode_text = "✅ Промокод" if "promocode" in selected_buttons else "🎫 Промокод"
keyboard = [
[
InlineKeyboardButton(text=balance_text, callback_data="btn_balance"),
InlineKeyboardButton(text=referrals_text, callback_data="btn_referrals")
],
[
InlineKeyboardButton(text=promocode_text, callback_data="btn_promocode")
]
]
selected_buttons = selected_buttons or []
keyboard: list[list[InlineKeyboardButton]] = []
for row in BROADCAST_BUTTON_ROWS:
row_buttons: list[InlineKeyboardButton] = []
for button_key in row:
button_config = BROADCAST_BUTTONS[button_key]
base_text = button_config["text"]
if button_key in selected_buttons:
if " " in base_text:
toggle_text = f"{base_text.split(' ', 1)[1]}"
else:
toggle_text = f"{base_text}"
else:
toggle_text = base_text
row_buttons.append(
InlineKeyboardButton(text=toggle_text, callback_data=f"btn_{button_key}")
)
if row_buttons:
keyboard.append(row_buttons)
if has_media:
keyboard.append([
InlineKeyboardButton(text="🖼️ Изменить медиа", callback_data="change_media")
])
keyboard.extend([
[
InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
@@ -985,5 +1001,5 @@ def get_updated_message_buttons_selector_keyboard_with_media(selected_buttons: l
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
]
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
+8
View File
@@ -475,6 +475,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
callback_data="topup_yookassa"
)
])
if settings.YOOKASSA_SBP_ENABLED:
keyboard.append([
InlineKeyboardButton(
text="🏦 Оплатить по СБП (YooKassa)",
callback_data="topup_yookassa_sbp"
)
])
if settings.TRIBUTE_ENABLED:
keyboard.append([
+2 -2
View File
@@ -119,7 +119,7 @@ class RussianTexts(Texts):
MENU_BUY_SUBSCRIPTION = "💎 Купить подписку"
MENU_EXTEND_SUBSCRIPTION = "⏰ Продлить подписку"
MENU_PROMOCODE = "🎫 Промокод"
MENU_REFERRALS = "🤝 Рефералы"
MENU_REFERRALS = "🤝 Партнерка"
MENU_SUPPORT = "🛠️ Техподдержка"
MENU_RULES = "📋 Правила сервиса"
MENU_LANGUAGE = "🌐 Язык"
@@ -421,7 +421,7 @@ class RussianTexts(Texts):
ADMIN_PROMOCODES = "🎫 Промокоды"
ADMIN_MESSAGES = "📨 Рассылки"
ADMIN_MONITORING = "🔍 Мониторинг"
ADMIN_REFERRALS = "🤝 Рефералы"
ADMIN_REFERRALS = "🤝 Партнерка"
ADMIN_RULES = "📋 Правила"
ADMIN_REMNAWAVE = "🖥️ Remnawave"
ADMIN_STATISTICS = "📊 Статистика"
+148 -64
View File
@@ -330,78 +330,25 @@ class BackupService:
models_by_table = {model.__tablename__: model for model in self.backup_models_ordered}
restore_order = []
await self._restore_users_without_referrals(db, backup_data, models_by_table)
for model in self.backup_models_ordered:
table_name = model.__tablename__
if table_name in backup_data and backup_data[table_name]:
restore_order.append(table_name)
for table_name in restore_order:
records = backup_data[table_name]
if not records:
continue
model = models_by_table.get(table_name)
if not model:
logger.warning(f"⚠️ Модель для таблицы {table_name} не найдена, пропускаем")
if table_name == "users":
continue
records = backup_data.get(table_name, [])
if not records:
continue
logger.info(f"🔥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
for record_data in records:
try:
processed_data = {}
for key, value in record_data.items():
if value is None:
processed_data[key] = None
continue
column = getattr(model.__table__.columns, key, None)
if column is None:
logger.warning(f"Колонка {key} не найдена в модели {table_name}")
continue
column_type_str = str(column.type).upper()
if ('DATETIME' in column_type_str or 'TIMESTAMP' in column_type_str) and isinstance(value, str):
try:
if 'T' in value:
processed_data[key] = datetime.fromisoformat(value.replace('Z', '+00:00'))
else:
processed_data[key] = datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
except (ValueError, TypeError) as e:
logger.warning(f"Не удалось парсить дату {value} для поля {key}: {e}")
processed_data[key] = datetime.utcnow()
elif ('BOOLEAN' in column_type_str or 'BOOL' in column_type_str) and isinstance(value, str):
processed_data[key] = value.lower() in ('true', '1', 'yes', 'on')
elif ('INTEGER' in column_type_str or 'INT' in column_type_str or 'BIGINT' in column_type_str) and isinstance(value, str):
try:
processed_data[key] = int(value)
except ValueError:
processed_data[key] = 0
elif ('FLOAT' in column_type_str or 'REAL' in column_type_str or 'NUMERIC' in column_type_str) and isinstance(value, str):
try:
processed_data[key] = float(value)
except ValueError:
processed_data[key] = 0.0
elif 'JSON' in column_type_str:
if isinstance(value, str) and value.strip():
try:
processed_data[key] = json_lib.loads(value)
except (ValueError, TypeError):
processed_data[key] = value
elif isinstance(value, (list, dict)):
processed_data[key] = value
else:
processed_data[key] = None
else:
processed_data[key] = value
processed_data = self._process_record_data(record_data, model, table_name)
primary_key_col = None
for col in model.__table__.columns:
if col.primary_key:
primary_key_col = col.name
break
primary_key_col = self._get_primary_key_column(model)
if primary_key_col and primary_key_col in processed_data:
existing_record = await db.execute(
@@ -413,7 +360,7 @@ class BackupService:
if existing and not clear_existing:
for key, value in processed_data.items():
if key != primary_key_col:
if key != primary_key_col:
setattr(existing, key, value)
logger.debug(f"Обновлена существующая запись {primary_key_col}={processed_data[primary_key_col]} в {table_name}")
else:
@@ -428,11 +375,14 @@ class BackupService:
except Exception as e:
logger.error(f"Ошибка восстановления записи в {table_name}: {e}")
logger.error(f"Проблемные данные: {record_data}")
continue
await db.rollback()
raise e
restored_tables += 1
logger.info(f"✅ Таблица {table_name} восстановлена")
await self._update_user_referrals(db, backup_data)
await db.commit()
break
@@ -465,6 +415,140 @@ class BackupService:
return False, error_msg
async def _restore_users_without_referrals(self, db: AsyncSession, backup_data: dict, models_by_table: dict):
users_data = backup_data.get("users", [])
if not users_data:
return
logger.info(f"👥 Восстанавливаем {len(users_data)} пользователей без реферальных связей")
User = models_by_table["users"]
for user_data in users_data:
try:
processed_data = self._process_record_data(user_data, User, "users")
processed_data['referred_by_id'] = None
if 'id' in processed_data:
existing_user = await db.execute(
select(User).where(User.id == processed_data['id'])
)
existing = existing_user.scalar_one_or_none()
if existing:
for key, value in processed_data.items():
if key != 'id':
setattr(existing, key, value)
else:
instance = User(**processed_data)
db.add(instance)
else:
instance = User(**processed_data)
db.add(instance)
except Exception as e:
logger.error(f"Ошибка при восстановлении пользователя: {e}")
await db.rollback()
raise e
await db.commit()
logger.info("✅ Пользователи без реферальных связей восстановлены")
async def _update_user_referrals(self, db: AsyncSession, backup_data: dict):
users_data = backup_data.get("users", [])
if not users_data:
return
logger.info("🔗 Обновляем реферальные связи пользователей")
for user_data in users_data:
try:
referred_by_id = user_data.get('referred_by_id')
user_id = user_data.get('id')
if referred_by_id and user_id:
referrer_result = await db.execute(
select(User).where(User.id == referred_by_id)
)
referrer = referrer_result.scalar_one_or_none()
if referrer:
user_result = await db.execute(
select(User).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()
if user:
user.referred_by_id = referred_by_id
else:
logger.warning(f"Пользователь {user_id} не найден для обновления реферальной связи")
else:
logger.warning(f"Реферер {referred_by_id} не найден для пользователя {user_id}")
except Exception as e:
logger.error(f"Ошибка при обновлении реферальной связи: {e}")
continue
await db.commit()
logger.info("✅ Реферальные связи обновлены")
def _process_record_data(self, record_data: dict, model, table_name: str) -> dict:
processed_data = {}
for key, value in record_data.items():
if value is None:
processed_data[key] = None
continue
column = getattr(model.__table__.columns, key, None)
if column is None:
logger.warning(f"Колонка {key} не найдена в модели {table_name}")
continue
column_type_str = str(column.type).upper()
if ('DATETIME' in column_type_str or 'TIMESTAMP' in column_type_str) and isinstance(value, str):
try:
if 'T' in value:
processed_data[key] = datetime.fromisoformat(value.replace('Z', '+00:00'))
else:
processed_data[key] = datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
except (ValueError, TypeError) as e:
logger.warning(f"Не удалось парсить дату {value} для поля {key}: {e}")
processed_data[key] = datetime.utcnow()
elif ('BOOLEAN' in column_type_str or 'BOOL' in column_type_str) and isinstance(value, str):
processed_data[key] = value.lower() in ('true', '1', 'yes', 'on')
elif ('INTEGER' in column_type_str or 'INT' in column_type_str or 'BIGINT' in column_type_str) and isinstance(value, str):
try:
processed_data[key] = int(value)
except ValueError:
processed_data[key] = 0
elif ('FLOAT' in column_type_str or 'REAL' in column_type_str or 'NUMERIC' in column_type_str) and isinstance(value, str):
try:
processed_data[key] = float(value)
except ValueError:
processed_data[key] = 0.0
elif 'JSON' in column_type_str:
if isinstance(value, str) and value.strip():
try:
processed_data[key] = json_lib.loads(value)
except (ValueError, TypeError):
processed_data[key] = value
elif isinstance(value, (list, dict)):
processed_data[key] = value
else:
processed_data[key] = None
else:
processed_data[key] = value
return processed_data
def _get_primary_key_column(self, model) -> Optional[str]:
for col in model.__table__.columns:
if col.primary_key:
return col.name
return None
async def _clear_database_tables(self, db: AsyncSession):
tables_order = [
"subscription_servers", "sent_notifications",
+88 -3
View File
@@ -261,6 +261,94 @@ class PaymentService:
except Exception as e:
logger.error(f"Ошибка создания платежа YooKassa: {e}")
return None
async def create_yookassa_sbp_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> Optional[Dict[str, Any]]:
if not self.yookassa_service:
logger.error("YooKassa сервис не инициализирован")
return None
try:
amount_rubles = amount_kopeks / 100
payment_metadata = metadata or {}
payment_metadata.update({
"user_id": str(user_id),
"amount_kopeks": str(amount_kopeks),
"type": "balance_topup_sbp"
})
yookassa_response = await self.yookassa_service.create_sbp_payment(
amount=amount_rubles,
currency="RUB",
description=description,
metadata=payment_metadata,
receipt_email=receipt_email,
receipt_phone=receipt_phone
)
if not yookassa_response or yookassa_response.get("error"):
logger.error(f"Ошибка создания платежа YooKassa СБП: {yookassa_response}")
return None
yookassa_created_at = None
if yookassa_response.get("created_at"):
try:
dt_with_tz = datetime.fromisoformat(
yookassa_response["created_at"].replace('Z', '+00:00')
)
yookassa_created_at = dt_with_tz.replace(tzinfo=None)
except Exception as e:
logger.warning(f"Не удалось парсить created_at: {e}")
yookassa_created_at = None
confirmation_token = None
if yookassa_response.get("confirmation"):
confirmation_token = yookassa_response["confirmation"].get("confirmation_token")
if confirmation_token:
payment_metadata["confirmation_token"] = confirmation_token
local_payment = await create_yookassa_payment(
db=db,
user_id=user_id,
yookassa_payment_id=yookassa_response["id"],
amount_kopeks=amount_kopeks,
currency="RUB",
description=description,
status=yookassa_response["status"],
confirmation_url=yookassa_response.get("confirmation_url"),
metadata_json=payment_metadata,
payment_method_type="bank_card",
yookassa_created_at=yookassa_created_at,
test_mode=yookassa_response.get("test_mode", False)
)
logger.info(f"Создан платеж YooKassa СБП {yookassa_response['id']} на {amount_rubles}₽ для пользователя {user_id}")
return {
"local_payment_id": local_payment.id,
"yookassa_payment_id": yookassa_response["id"],
"confirmation_url": yookassa_response.get("confirmation_url"),
"confirmation_token": confirmation_token,
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_rubles,
"status": yookassa_response["status"],
"created_at": local_payment.created_at
}
except Exception as e:
logger.error(f"Ошибка создания платежа YooKassa СБП: {e}")
return None
async def process_yookassa_webhook(self, db: AsyncSession, webhook_data: dict) -> bool:
try:
@@ -679,10 +767,8 @@ class PaymentService:
)
if not updated_payment.transaction_id:
# Получаем сумму в USD из платежа
amount_usd = updated_payment.amount_float
# Конвертируем в рубли по текущему курсу с улучшенной обработкой ошибок
try:
amount_rubles = await currency_converter.usd_to_rub(amount_usd)
amount_kopeks = int(amount_rubles * 100)
@@ -694,7 +780,6 @@ class PaymentService:
amount_kopeks = int(amount_usd * 100)
conversion_rate = 1.0
# Проверяем корректность конвертированной суммы
if amount_kopeks <= 0:
logger.error(f"Некорректная сумма после конвертации: {amount_kopeks} копеек для платежа {invoice_id}")
return False
+56 -17
View File
@@ -19,6 +19,21 @@ from app.utils.pricing_utils import (
logger = logging.getLogger(__name__)
def get_traffic_reset_strategy():
from app.config import settings
strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
strategy_mapping = {
'NO_RESET': 'NO_RESET',
'DAY': 'DAY',
'WEEK': 'WEEK',
'MONTH': 'MONTH'
}
mapped_strategy = strategy_mapping.get(strategy, 'NO_RESET')
logger.info(f"🔄 Стратегия сброса трафика из конфига: {strategy} -> {mapped_strategy}")
return getattr(TrafficLimitStrategy, mapped_strategy)
class SubscriptionService:
@@ -66,7 +81,7 @@ class SubscriptionService:
status=UserStatus.ACTIVE,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(),
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
@@ -84,7 +99,7 @@ class SubscriptionService:
expire_at=subscription.end_date,
status=UserStatus.ACTIVE,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(),
telegram_id=user.telegram_id,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
@@ -103,7 +118,8 @@ class SubscriptionService:
logger.info(f"✅ Создан/обновлен RemnaWave пользователь для подписки {subscription.id}")
logger.info(f"🔗 Ссылка на подписку: {updated_user.subscription_url}")
logger.info(f"📊 Стратегия сброса трафика: MONTH")
strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY
logger.info(f"📊 Стратегия сброса трафика: {strategy_name}")
return updated_user
except RemnaWaveAPIError as e:
@@ -144,7 +160,7 @@ class SubscriptionService:
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(),
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
@@ -159,7 +175,8 @@ class SubscriptionService:
status_text = "активным" if is_actually_active else "истёкшим"
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}")
logger.info(f"📊 Стратегия сброса трафика: MONTH")
strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY
logger.info(f"📊 Стратегия сброса трафика: {strategy_name}")
return updated_user
except RemnaWaveAPIError as e:
@@ -283,9 +300,12 @@ class SubscriptionService:
logger.info(f"Расчет стоимости новой подписки:")
logger.info(f" Период {period_days} дней: {base_price/100}")
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price/100}")
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}")
logger.info(f" Устройства ({devices}): {devices_price/100}")
if traffic_price > 0:
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price/100}")
if total_servers_price > 0:
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}")
if devices_price > 0:
logger.info(f" Устройства ({devices}): {devices_price/100}")
logger.info(f" ИТОГО: {total_price/100}")
return total_price, server_prices
@@ -313,9 +333,12 @@ class SubscriptionService:
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
logger.info(f" 📅 Период {period_days} дней: {base_price/100}")
logger.info(f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {servers_price/100}")
logger.info(f" 📱 Устройства ({subscription.device_limit}): {devices_price/100}")
logger.info(f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {traffic_price/100}")
if servers_price > 0:
logger.info(f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {servers_price/100}")
if devices_price > 0:
logger.info(f" 📱 Устройства ({subscription.device_limit}): {devices_price/100}")
if traffic_price > 0:
logger.info(f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {traffic_price/100}")
logger.info(f" 💎 ИТОГО: {total_price/100}")
return total_price
@@ -458,9 +481,16 @@ class SubscriptionService:
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
logger.info(f" Период {period_days} дней: {base_price/100}")
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}")
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}")
logger.info(f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}")
if total_traffic_price > 0:
logger.info(
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}"
)
if total_servers_price > 0:
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}")
if total_devices_price > 0:
logger.info(
f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}"
)
logger.info(f" ИТОГО: {total_price/100}")
return total_price, server_prices
@@ -494,9 +524,18 @@ class SubscriptionService:
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
logger.info(f" 📅 Период {period_days} дней: {base_price/100}")
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}")
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}")
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}")
if total_servers_price > 0:
logger.info(
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}"
)
if total_devices_price > 0:
logger.info(
f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}"
)
if total_traffic_price > 0:
logger.info(
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}"
)
logger.info(f" 💎 ИТОГО: {total_price/100}")
return total_price
+108 -8
View File
@@ -3,6 +3,8 @@ from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete, select, update
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from app.database.crud.user import (
get_user_by_id, get_user_by_telegram_id, get_users_list,
get_users_count, get_users_statistics, get_inactive_users,
@@ -23,6 +25,59 @@ logger = logging.getLogger(__name__)
class UserService:
async def _send_balance_notification(
self,
bot: Bot,
user: User,
amount_kopeks: int,
admin_name: str
) -> bool:
"""Отправляет уведомление пользователю о пополнении/списании баланса"""
try:
if amount_kopeks > 0:
# Пополнение
emoji = "💰"
action = "пополнен"
amount_text = f"+{settings.format_price(amount_kopeks)}"
message = (
f"{emoji} <b>Баланс пополнен!</b>\n\n"
f"💵 <b>Сумма:</b> {amount_text}\n"
f"👤 <b>Администратор:</b> {admin_name}\n"
f"💳 <b>Текущий баланс:</b> {settings.format_price(user.balance_kopeks)}\n\n"
f"Спасибо за использование нашего сервиса! 🎉"
)
else:
# Списание
emoji = "💸"
action = "списан"
amount_text = f"-{settings.format_price(abs(amount_kopeks))}"
message = (
f"{emoji} <b>Средства списаны с баланса</b>\n\n"
f"💵 <b>Сумма:</b> {amount_text}\n"
f"👤 <b>Администратор:</b> {admin_name}\n"
f"💳 <b>Текущий баланс:</b> {settings.format_price(user.balance_kopeks)}\n\n"
f"Если у вас есть вопросы, обратитесь в поддержку."
)
await bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode="HTML"
)
logger.info(f"✅ Уведомление о изменении баланса отправлено пользователю {user.telegram_id}")
return True
except TelegramForbiddenError:
logger.warning(f"⚠️ Пользователь {user.telegram_id} заблокировал бота")
return False
except TelegramBadRequest as e:
logger.error(f"❌ Ошибка Telegram API при отправке уведомления пользователю {user.telegram_id}: {e}")
return False
except Exception as e:
logger.error(f"❌ Неожиданная ошибка при отправке уведомления пользователю {user.telegram_id}: {e}")
return False
async def get_user_profile(
self,
db: AsyncSession,
@@ -128,22 +183,41 @@ class UserService:
user_id: int,
amount_kopeks: int,
description: str,
admin_id: int
admin_id: int,
bot: Optional[Bot] = None,
admin_name: Optional[str] = None
) -> bool:
try:
user = await get_user_by_id(db, user_id)
if not user:
return False
# Сохраняем старый баланс для уведомления
old_balance = user.balance_kopeks
if amount_kopeks > 0:
await add_user_balance(db, user, amount_kopeks, description=description)
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}")
return True
success = True
else:
success = await subtract_user_balance(db, user, abs(amount_kopeks), description)
if success:
logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}")
return success
# Отправляем уведомление пользователю, если операция прошла успешно
if success and bot:
# Обновляем пользователя для получения нового баланса
await db.refresh(user)
# Получаем имя администратора
if not admin_name:
admin_user = await get_user_by_id(db, admin_id)
admin_name = admin_user.full_name if admin_user else f"Админ #{admin_id}"
# Отправляем уведомление (не блокируем операцию если не удалось отправить)
await self._send_balance_notification(bot, user, amount_kopeks, admin_name)
return success
except Exception as e:
logger.error(f"Ошибка изменения баланса пользователя: {e}")
@@ -239,13 +313,39 @@ class UserService:
logger.info(f"🗑️ Начинаем полное удаление пользователя {user_id} (Telegram ID: {user.telegram_id})")
if user.remnawave_uuid:
from app.config import settings
delete_mode = settings.get_remnawave_user_delete_mode()
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован")
from app.services.remnawave_service import RemnaWaveService
remnawave_service = RemnaWaveService()
if delete_mode == "delete":
# Удаляем пользователя из панели Remnawave
async with remnawave_service.api as api:
delete_success = await api.delete_user(user.remnawave_uuid)
if delete_success:
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} удален из панели")
else:
logger.warning(f"⚠️ Не удалось удалить пользователя {user.remnawave_uuid} из панели Remnawave")
else:
# Деактивируем пользователя в панели Remnawave
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован (режим: {delete_mode})")
except Exception as e:
logger.warning(f"⚠️ Ошибка деактивации RemnaWave: {e}")
logger.warning(f"⚠️ Ошибка обработки пользователя в Remnawave (режим: {delete_mode}): {e}")
# Если основное действие не удалось, попытаемся хотя бы деактивировать
if delete_mode == "delete":
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован как fallback")
except Exception as fallback_e:
logger.error(f"❌ Ошибка деактивации RemnaWave как fallback: {fallback_e}")
try:
sent_notifications_result = await db.execute(
+104 -1
View File
@@ -11,7 +11,6 @@ from app.config import settings
logger = logging.getLogger(__name__)
class YooKassaService:
def __init__(self,
@@ -149,6 +148,110 @@ class YooKassaService:
logger.error(f"Ошибка создания платежа YooKassa: {e}", exc_info=True)
return None
async def create_sbp_payment(
self,
amount: float,
currency: str,
description: str,
metadata: Dict[str, Any],
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
if not self.configured:
logger.error("YooKassa не сконфигурирован. Невозможно создать платеж через СБП.")
return None
customer_contact_for_receipt = {}
if receipt_email:
customer_contact_for_receipt["email"] = receipt_email
elif receipt_phone:
customer_contact_for_receipt["phone"] = receipt_phone
elif hasattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL') and settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
customer_contact_for_receipt["email"] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
else:
logger.error(
"КРИТИЧНО: Не предоставлен email/телефон для чека YooKassa и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.")
return {
"error": True,
"internal_message": "Отсутствуют контактные данные для чека YooKassa и не настроен email по умолчанию."
}
try:
builder = PaymentRequestBuilder()
builder.set_amount({
"value": str(round(amount, 2)),
"currency": currency.upper()
})
builder.set_capture(True)
builder.set_confirmation({
"type": "redirect",
"return_url": self.return_url
})
builder.set_description(description)
builder.set_metadata(metadata)
builder.set_payment_method_data({
"type": "sbp"
})
receipt_items_list: List[Dict[str, Any]] = [{
"description": description[:128],
"quantity": "1.00",
"amount": {
"value": str(round(amount, 2)),
"currency": currency.upper()
},
"vat_code": str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
"payment_mode": getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
"payment_subject": getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service')
}]
receipt_data_dict: Dict[str, Any] = {
"customer": customer_contact_for_receipt,
"items": receipt_items_list
}
builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
logger.info(
f"Создание платежа YooKassa СБП (Idempotence-Key: {idempotence_key}). "
f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}")
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None, lambda: YooKassaPayment.create(payment_request, idempotence_key))
logger.info(
f"Ответ YooKassa Payment.create (СБП): ID={response.id}, Status={response.status}, Paid={response.paid}")
return {
"id": response.id,
"confirmation_url": response.confirmation.confirmation_url if response.confirmation else None,
"status": response.status,
"metadata": response.metadata,
"amount_value": float(response.amount.value),
"amount_currency": response.amount.currency,
"idempotence_key_used": idempotence_key,
"paid": response.paid,
"refundable": response.refundable,
"created_at": response.created_at.isoformat() if hasattr(
response.created_at, 'isoformat') else str(response.created_at),
"description_from_yk": response.description,
"test_mode": response.test if hasattr(response, 'test') else None
}
except Exception as e:
logger.error(f"Ошибка создания платежа YooKassa СБП: {e}", exc_info=True)
return None
async def get_payment_info(
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
+20 -14
View File
@@ -3,7 +3,7 @@ import secrets
import string
from datetime import datetime, timedelta
from typing import Optional, Dict, List
from sqlalchemy import select, func, and_
from sqlalchemy import select, func, and_, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -28,25 +28,31 @@ async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> s
return f"ref{timestamp}"
async def mark_user_as_had_paid_subscription(db: AsyncSession, user_id: int) -> bool:
async def mark_user_as_had_paid_subscription(db: AsyncSession, user: User) -> bool:
try:
from app.database.crud.user import get_user_by_id
if user.has_had_paid_subscription:
logger.debug(f"Пользователь {user.id} уже отмечен как имевший платную подписку")
return True
user = await get_user_by_id(db, user_id)
if not user:
logger.error(f"Пользователь {user_id} не найден")
return False
if not user.has_had_paid_subscription:
user.has_had_paid_subscription = True
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"✅ Пользователь {user_id} отмечен как имевший платную подписку")
await db.execute(
update(User)
.where(User.id == user.id)
.values(
has_had_paid_subscription=True,
updated_at=datetime.utcnow()
)
)
await db.commit()
logger.info(f"✅ Пользователь {user.id} отмечен как имевший платную подписку")
return True
except Exception as e:
logger.error(f"Ошибка отметки пользователя {user_id} как имевшего платную подписку: {e}")
logger.error(f"Ошибка отметки пользователя {user.id} как имевшего платную подписку: {e}")
try:
await db.rollback()
except Exception as rollback_error:
logger.error(f"Ошибка отката транзакции: {rollback_error}")
return False
+12 -12
View File
@@ -1,28 +1,28 @@
# Основные зависимости
aiogram==3.7.0
aiohttp==3.9.1
asyncpg==0.29.0
SQLAlchemy==2.0.25
alembic==1.13.1
aiosqlite==0.19.0
aiogram==3.22.0
aiohttp==3.12.15
asyncpg==0.30.0
SQLAlchemy==2.0.43
alembic==1.16.5
aiosqlite==0.21.0
# Дополнительные зависимости
pydantic==2.5.3
pydantic-settings==2.1.0
python-dotenv==1.0.0
pydantic==2.11.9
pydantic-settings==2.10.1
python-dotenv==1.1.1
redis==5.0.1
# YooKassa SDK
yookassa==3.0.0
yookassa==3.7.0
# Логирование и мониторинг
structlog==23.2.0
# Планировщик задач для техработ
APScheduler==3.10.4
APScheduler==3.11.0
# Утилиты
python-dateutil==2.8.2
python-dateutil==2.9.0.post0
pytz==2023.4
cryptography>=41.0.0
qrcode[pil]==7.4.2