Compare commits

...

116 Commits

Author SHA1 Message Date
Egor 813193102c Update Dockerfile 2025-11-10 06:28:50 +03:00
Egor 42112d6c8a Update docker-registry.yml 2025-11-10 06:28:40 +03:00
Egor dfe5e36fe0 Update docker-hub.yml 2025-11-10 06:28:31 +03:00
Egor b5d4a54e97 Update README.md 2025-11-10 06:22:26 +03:00
Egor 1eaf7204ce Merge pull request #1859 from Fr1ngg/dev4
fix #1849 Round CryptoBot top-up amounts up to whole rubles
2025-11-10 06:13:51 +03:00
Egor 8cc37dc439 Merge pull request #1858 from Fr1ngg/bedolaga/fix-webhook-handler-error-in-cryptobot-i1csym
Round CryptoBot top-up amounts up to whole rubles
2025-11-10 06:11:42 +03:00
Egor ccafb18122 Round CryptoBot top-up amounts up to whole rubles 2025-11-10 06:11:18 +03:00
Egor 654b132ae6 Merge pull request #1857 from Fr1ngg/revert-1855-bedolaga/fix-webhook-handler-error-in-cryptobot
Revert "Refactor CryptoBot notifications to avoid transaction rollback"
2025-11-10 06:11:05 +03:00
Egor 815fa0560c Revert "Refactor CryptoBot notifications to avoid transaction rollback" 2025-11-10 06:10:54 +03:00
Egor c4d19810a4 Merge pull request #1856 from Fr1ngg/dev4
fix
2025-11-10 06:05:55 +03:00
Egor 9fbb32eb5b Merge pull request #1855 from Fr1ngg/bedolaga/fix-webhook-handler-error-in-cryptobot
Refactor CryptoBot notifications to avoid transaction rollback
2025-11-10 05:59:09 +03:00
Egor 3ce3cde859 Refactor CryptoBot notifications to avoid transaction rollback 2025-11-10 05:53:26 +03:00
Egor 4e71dcd2b1 Merge pull request #1853 from Fr1ngg/dev4
Update Pal24 callback handling and SBP link fallback
2025-11-10 05:33:11 +03:00
Egor acf21fa6d2 Merge pull request #1852 from Fr1ngg/bedolaga/update-requirements.txt-and-fix-pal24-webhook
Update Pal24 callback handling and SBP link fallback
2025-11-10 05:29:04 +03:00
Egor f2b724a78d Adjust Pal24 callback handling and SBP link fallback 2025-11-10 05:26:02 +03:00
Egor 03473360c1 Merge pull request #1851 from Fr1ngg/dev4
Dev4
2025-11-10 04:30:16 +03:00
Egor 27dc68be41 Merge pull request #1850 from Fr1ngg/bedolaga/fix-webhook-bot-error-handling
Prevent maintenance monitoring from crashing without RemnaWave URL
2025-11-10 04:26:35 +03:00
Egor b15d78f8af Handle missing RemnaWave config in maintenance monitoring 2025-11-10 04:25:52 +03:00
Egor d232d14937 Merge pull request #1848 from Fr1ngg/dev4
fix migration on new bases
2025-11-10 04:13:19 +03:00
Egor 927d469db2 Merge pull request #1847 from Fr1ngg/bedolaga/fix-database-migration-crash-on-first-bot-launch
Handle missing tables when creating indexes during init_db
2025-11-10 04:06:46 +03:00
Egor e20e9e7cec Fix init_db index creation on fresh databases 2025-11-10 04:06:28 +03:00
Egor 0f097c3108 Merge pull request #1830 from Gy9vin/main
Фикс простой покупки!
2025-11-10 03:37:31 +03:00
gy9vin eaa3c80d59 Фикс простой покупки! 2025-11-09 21:55:58 +03:00
Egor 0c49eddfde Merge pull request #1828 from Gy9vin/main
Фикс проверки зачисления платежа юкассы(защита от дублирования)
2025-11-09 20:09:15 +03:00
gy9vin 33882f1e93 Фикс проверки зачисления платежа юкассы(защита от дублирования) 2025-11-09 18:31:39 +03:00
Egor f94666b68d Merge pull request #1826 from Fr1ngg/dev4
Dev4
2025-11-09 10:29:27 +03:00
Egor d32a49f801 Merge pull request #1825 from Fr1ngg/bedolaga/fix-bulk_update-to-include-where-clause
Fix bulk_update to target records by primary key
2025-11-09 10:29:11 +03:00
Egor 9596f82ea6 Fix bulk_update to target records by primary key 2025-11-09 10:28:47 +03:00
Egor 0e3fdf7d61 Merge pull request #1824 from Fr1ngg/dev4
Razgonchik
2025-11-09 10:24:57 +03:00
Egor 54cfec6029 Merge pull request #1823 from Fr1ngg/bedolaga/fix-repeated-yookassa-deposits-y7sxo6
Ensure YooKassa retries finish pending credits
2025-11-09 10:13:20 +03:00
Egor 9e88eae416 Ensure YooKassa retries finish pending credits 2025-11-09 10:13:01 +03:00
Egor 8d32efac73 Merge pull request #1822 from Fr1ngg/revert-1821-bedolaga/fix-repeated-yookassa-deposits
Revert "Prevent double processing of YooKassa payments"
2025-11-09 10:04:02 +03:00
Egor 9f07b6007e Revert "Prevent double processing of YooKassa payments" 2025-11-09 10:03:54 +03:00
Egor 2fb3b6c5dc Merge pull request #1821 from Fr1ngg/bedolaga/fix-repeated-yookassa-deposits
Prevent double processing of YooKassa payments
2025-11-09 10:03:38 +03:00
Egor 0637576425 Prevent double processing of YooKassa payments 2025-11-09 10:00:17 +03:00
Egor ee7a445610 Update database.py 2025-11-09 09:46:40 +03:00
Egor ba4c7ee7ac Update database.py 2025-11-09 09:43:26 +03:00
Egor 4da169eb0f Merge pull request #1819 from Fr1ngg/bedolaga/fix-health-endpoints-for-sqlite-nullpool
Handle missing pool metrics for NullPool
2025-11-09 09:40:53 +03:00
Egor 28937abab4 Handle missing pool metrics for NullPool 2025-11-09 09:40:08 +03:00
Egor b1dcf2779e Update database.py 2025-11-09 09:25:59 +03:00
Egor 3219062ce2 Update database.py 2025-11-09 09:22:36 +03:00
Egor 2ac72951f8 Merge pull request #1818 from Fr1ngg/bedolaga/add-health-and-metrics-endpoints-to-fastapi
Add database health and pool metrics endpoints
2025-11-09 09:17:35 +03:00
Egor cefb8cba78 Add database health and pool metrics endpoints 2025-11-09 09:17:18 +03:00
Egor e869655028 Update database.py 2025-11-09 09:11:47 +03:00
Egor ae03ede209 Merge pull request #1817 from Fr1ngg/dev4
Dev
2025-11-09 08:34:49 +03:00
Egor a72bb25ea2 Update database.py 2025-11-09 08:30:38 +03:00
Egor 0b22bc7056 Merge pull request #1815 from Fr1ngg/bedolaga/-nullpool-queuepool-wh494i
Handle optional Pal24 metadata fields
2025-11-09 08:24:26 +03:00
Egor cde84ff20b Handle optional Pal24 metadata fields 2025-11-09 08:23:01 +03:00
Egor f76114d7dc Update database.py 2025-11-09 08:17:43 +03:00
Egor 9708a56fd7 Update database.py 2025-11-09 08:14:41 +03:00
Egor a360337660 Merge pull request #1813 from Fr1ngg/dev4
фикс бекапов/восстановления
2025-11-09 07:39:18 +03:00
Egor 9f78335651 Merge pull request #1812 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism-1wazcg
Avoid duplicate association errors during restore
2025-11-09 07:31:17 +03:00
Egor ef8142a99b Skip duplicate squad promo associations during restore 2025-11-09 07:30:54 +03:00
Egor 06d51f9fcf Merge pull request #1811 from Fr1ngg/revert-1810-bedolaga/improve-backup-and-restore-mechanism-gn1h90
Revert "Handle PostgreSQL backups without pg_dump"
2025-11-09 07:30:41 +03:00
Egor 4d94347179 Revert "Handle PostgreSQL backups without pg_dump" 2025-11-09 07:30:31 +03:00
Egor 2f6aa0b1e6 Merge pull request #1810 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism-gn1h90
Handle PostgreSQL backups without pg_dump
2025-11-09 07:23:11 +03:00
Egor 536a58d4d3 Handle PostgreSQL backups without pg_dump 2025-11-09 07:22:50 +03:00
Egor 62f7c07059 Merge pull request #1809 from Fr1ngg/revert-1808-bedolaga/improve-backup-and-restore-mechanism
Revert "Refactor backup pipeline to use archive dumps"
2025-11-09 07:22:10 +03:00
Egor 0648edb3c4 Revert "Refactor backup pipeline to use archive dumps" 2025-11-09 07:21:56 +03:00
Egor 9b6c235424 Merge pull request #1808 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism
Refactor backup pipeline to use archive dumps
2025-11-09 07:12:18 +03:00
Egor 2fc35950cf Refactor backup system with archive dumps 2025-11-09 07:12:01 +03:00
Egor d2a5d74239 Merge pull request #1807 from Fr1ngg/dev4
Dev4
2025-11-09 07:06:24 +03:00
Egor 6b04c6748e Merge pull request #1806 from Fr1ngg/bedolaga/add-trial-activation-funds-check
Prefill top-up amount for paid trial activation
2025-11-09 06:58:03 +03:00
Egor 10b44cbdb7 Prefill top-up amount for paid trial activation 2025-11-09 06:51:50 +03:00
Egor dde04c716c Merge pull request #1805 from Fr1ngg/bedolaga/fix-variable-loading-for-minimum-sum
Adjust Platega top-up prompt to show configured limits
2025-11-09 06:44:55 +03:00
Egor 819f19a7ea Update Platega prompt to use configured limits 2025-11-09 06:44:23 +03:00
Egor e7201597ab Merge pull request #1804 from Fr1ngg/bedolaga/fix-pall24-payment-method-issue
Fix Pal24 SBP link handling after status checks
2025-11-09 06:35:54 +03:00
Egor ea61aa7053 Fix Pal24 SBP link handling after status checks 2025-11-09 06:31:02 +03:00
Egor 14546a8485 Merge pull request #1803 from Fr1ngg/bedolaga/add-ip-validation-for-yookassa-webhooks
Handle Cloudflare proxied YooKassa webhooks
2025-11-09 06:22:17 +03:00
Egor f55455761f Trust Cloudflare headers for YooKassa webhooks 2025-11-09 06:21:40 +03:00
Egor b1a7767827 Merge pull request #1802 from Fr1ngg/revert-1801-bedolaga/add-server-category-feature-to-admin-panel-ltnwjl
Revert "Fix server category button routing in admin panel"
2025-11-09 05:55:16 +03:00
Egor 43c04b7e64 Revert "Fix server category button routing in admin panel" 2025-11-09 05:55:07 +03:00
Egor add679cf6f Merge pull request #1801 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel-ltnwjl
Fix server category button routing in admin panel
2025-11-09 05:49:00 +03:00
Egor ee173190a0 Fix category edit menu callback 2025-11-09 05:48:45 +03:00
Egor a6e21b47f6 Merge pull request #1800 from Fr1ngg/revert-1799-bedolaga/add-server-category-feature-to-admin-panel-fwofd2
Revert "Fix category pricing to ignore full squads"
2025-11-09 05:40:56 +03:00
Egor cd627e5840 Revert "Fix category pricing to ignore full squads" 2025-11-09 05:40:47 +03:00
Egor fc096e6867 Merge pull request #1799 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel-fwofd2
Fix category pricing to ignore full squads
2025-11-09 05:33:29 +03:00
Egor b591844c4e Fix category pricing to ignore full squads 2025-11-09 05:33:13 +03:00
Egor 9557acb546 Merge pull request #1798 from Fr1ngg/revert-1797-bedolaga/add-server-category-feature-to-admin-panel
Revert "Ensure server category selection skips full squads"
2025-11-09 05:31:20 +03:00
Egor 1fa6e75bd3 Revert "Ensure server category selection skips full squads" 2025-11-09 05:31:09 +03:00
Egor 8965e43a55 Merge pull request #1797 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel
Ensure server category selection skips full squads
2025-11-09 05:24:50 +03:00
Egor 8e7a6beece Ensure category auto-selection respects capacity 2025-11-09 05:23:58 +03:00
Egor e9f4fd8007 Merge pull request #1796 from Fr1ngg/revert-1793-bedolaga/implement-server-grouping-system-for-vpn-bot
Revert "Add server group backend selection logic"
2025-11-09 04:54:55 +03:00
Egor a991c59d58 Revert "Add server group backend selection logic" 2025-11-09 04:54:47 +03:00
Egor dd0624adf8 Merge pull request #1795 from Fr1ngg/revert-1794-bedolaga/check-functionality-for-server-grouping
Revert "Fix autopurchase server selection and RemnaWave stats handling"
2025-11-09 04:54:31 +03:00
Egor ab51cbe96a Revert "Fix autopurchase server selection and RemnaWave stats handling" 2025-11-09 04:54:24 +03:00
Egor 2f7a08deb0 Merge pull request #1794 from Fr1ngg/bedolaga/check-functionality-for-server-grouping
Fix autopurchase server selection and RemnaWave stats handling
2025-11-09 04:50:50 +03:00
Egor a0fa7f986b Fix autopurchase server selection and RemnaWave stats handling 2025-11-09 04:50:32 +03:00
Egor b2d3eebe39 Merge pull request #1793 from Fr1ngg/bedolaga/implement-server-grouping-system-for-vpn-bot
Add server group backend selection logic
2025-11-09 04:39:54 +03:00
Egor a043fc0e46 feat: add backend support for server groups 2025-11-09 04:36:33 +03:00
Egor d6eec8787e Merge pull request #1792 from Fr1ngg/bedolaga/fix-payment-methods-display-for-platega.io
Add Platega payment option to balance top-up list
2025-11-09 03:31:21 +03:00
Egor 95eee42047 Добавить Platega в список пополнения 2025-11-09 03:30:49 +03:00
Egor c673d6db4f Merge pull request #1790 from Fr1ngg/dev4
Dev4
2025-11-08 18:58:33 +03:00
Egor bb32a63d48 Merge pull request #1789 from Fr1ngg/bedolaga/fix-null-value-constraint-in-promo_groups
Fix default promo group creation with priority column
2025-11-08 18:58:13 +03:00
Egor af5acb272c Fix default promo group creation with priority column 2025-11-08 18:57:53 +03:00
Egor 5cd5ac8710 Merge pull request #1788 from Fr1ngg/revert-1787-dev4
Revert "Dev4"
2025-11-08 12:05:27 +03:00
Egor 8bb58b44b3 Revert "Dev4" 2025-11-08 12:05:12 +03:00
Egor 511ecd506f Merge pull request #1787 from Fr1ngg/dev4
Dev4
2025-11-08 11:38:41 +03:00
Egor 62fc014bc3 Restore json serialization helper for YooKassa webhook tests 2025-11-08 11:38:18 +03:00
Egor 961f564274 Merge pull request #1786 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for-lcydgf
Remove YooKassa IP filtering from webhooks
2025-11-08 11:27:53 +03:00
Egor 984870c78c Remove YooKassa IP filtering from webhooks 2025-11-08 11:27:37 +03:00
Egor 6196c135d2 Merge pull request #1785 from Fr1ngg/revert-1782-bedolaga/fix-webhook-ip-verification-for-w66vvd
Revert "Verify YooKassa webhooks against API"
2025-11-08 11:27:23 +03:00
Egor bf1b8315a8 Revert "Verify YooKassa webhooks against API" 2025-11-08 11:27:13 +03:00
Egor b7eabcf7e7 Merge pull request #1784 from Fr1ngg/revert-1783-dev4
Revert "Dev4"
2025-11-08 11:08:55 +03:00
Egor e83ebc4f02 Revert "Dev4" 2025-11-08 11:08:47 +03:00
Egor 1a4758f4cc Merge pull request #1783 from Fr1ngg/dev4
Dev4
2025-11-08 11:04:08 +03:00
Egor b80e8b3804 Merge pull request #1782 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for-w66vvd
Verify YooKassa webhooks against API
2025-11-08 11:00:49 +03:00
Egor e036173670 Verify YooKassa webhooks against API 2025-11-08 11:00:28 +03:00
Egor bd728c6466 Merge pull request #1781 from Fr1ngg/revert-1777-bedolaga/fix-webhook-ip-verification-for
Revert "Handle Cloudflare forwarded IPs for YooKassa webhooks"
2025-11-08 10:58:48 +03:00
Egor e3aec310a3 Revert "Handle Cloudflare forwarded IPs for YooKassa webhooks" 2025-11-08 10:58:34 +03:00
Egor 1c59ce601e Merge pull request #1780 from Fr1ngg/revert-1779-dev4
Revert "da eb tvoyu mat' kakoy pidoras delal etu ukassu "
2025-11-08 10:58:21 +03:00
Egor 0563f7524f Revert "da eb tvoyu mat' kakoy pidoras delal etu ukassu " 2025-11-08 10:58:12 +03:00
Egor cedbe07759 Merge pull request #1779 from Fr1ngg/dev4
da eb tvoyu mat' kakoy pidoras delal etu ukassu
2025-11-08 10:53:02 +03:00
Egor 0746b2a5a7 Merge pull request #1778 from Fr1ngg/main
w
2025-11-08 10:52:15 +03:00
Egor e8a72860dc Merge pull request #1777 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for
Handle Cloudflare forwarded IPs for YooKassa webhooks
2025-11-08 10:51:50 +03:00
Egor abbfe4a7d3 Handle Cloudflare forwarded IPs for YooKassa webhooks 2025-11-08 10:50:56 +03:00
33 changed files with 2339 additions and 490 deletions
+3 -3
View File
@@ -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.6.1-$(git rev-parse --short HEAD)"
VERSION="v2.6.2-$(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.6.1-dev-$(git rev-parse --short HEAD)"
VERSION="v2.6.2-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.6.1-pr-$(git rev-parse --short HEAD)"
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.6.1-$(git rev-parse --short HEAD)"
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.6.1-dev-$(git rev-parse --short HEAD)"
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.6.1-pr-$(git rev-parse --short HEAD)"
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.6.1"
ARG VERSION="v2.6.2"
ARG BUILD_DATE
ARG VCS_REF
+1 -1
View File
@@ -437,7 +437,7 @@ http {
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
[Полный список доступных параметров:](remnawave-bedolaga-telegram-bot/blob/main/.env.example)
[Полный список доступных параметров:](.env.example)
### 🌐 Интеграция веб-админки
+23
View File
@@ -0,0 +1,23 @@
"""Database package exports."""
from .database import (
DatabaseManager,
batch_ops,
close_db,
db_manager,
get_db,
get_db_read_only,
get_pool_metrics,
init_db,
)
__all__ = [
"DatabaseManager",
"batch_ops",
"close_db",
"db_manager",
"get_db",
"get_db_read_only",
"get_pool_metrics",
"init_db",
]
+360 -14
View File
@@ -1,31 +1,167 @@
import logging
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.pool import NullPool
from typing import AsyncGenerator, Optional
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import (
AsyncSession,
create_async_engine,
async_sessionmaker,
AsyncEngine
)
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
from sqlalchemy import event, text, bindparam, inspect
from sqlalchemy.engine import Engine
import time
from app.config import settings
from app.database.models import Base
logger = logging.getLogger(__name__)
# ============================================================================
# PRODUCTION-GRADE CONNECTION POOLING
# ============================================================================
if settings.get_database_url().startswith("sqlite"):
poolclass = NullPool
pool_kwargs = {}
else:
poolclass = AsyncAdaptedQueuePool
pool_kwargs = {
"pool_size": 20,
"max_overflow": 30,
"pool_timeout": 30,
"pool_recycle": 3600,
"pool_pre_ping": True,
# 🔥 Агрессивная очистка мертвых соединений
"pool_reset_on_return": "rollback",
}
# ============================================================================
# ENGINE WITH ADVANCED OPTIMIZATIONS
# ============================================================================
engine = create_async_engine(
settings.get_database_url(),
poolclass=NullPool,
poolclass=poolclass,
echo=settings.DEBUG,
future=True
future=True,
**pool_kwargs,
connect_args={
"server_settings": {
"application_name": "remnawave_bot",
"jit": "on",
"statement_timeout": "60000", # 60 секунд
"idle_in_transaction_session_timeout": "300000", # 5 минут
},
"command_timeout": 60,
"timeout": 10,
} if not settings.get_database_url().startswith("sqlite") else {},
execution_options={
"isolation_level": "READ COMMITTED", # Оптимальный для большинства случаев
"compiled_cache_size": 500, # Кеш скомпилированных запросов
}
)
# ============================================================================
# SESSION FACTORY WITH OPTIMIZATIONS
# ============================================================================
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=True,
autocommit=False
autoflush=False, # 🔥 Критично для производительности
autocommit=False,
)
# ============================================================================
# QUERY PERFORMANCE MONITORING
# ============================================================================
if settings.DEBUG:
@event.listens_for(Engine, "before_cursor_execute")
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
conn.info.setdefault("query_start_time", []).append(time.time())
logger.debug(f"🔍 Executing query: {statement[:100]}...")
@event.listens_for(Engine, "after_cursor_execute")
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
total = time.time() - conn.info["query_start_time"].pop(-1)
if total > 0.1: # Логируем медленные запросы > 100ms
logger.warning(f"🐌 Slow query ({total:.3f}s): {statement[:100]}...")
else:
logger.debug(f"⚡ Query executed in {total:.3f}s")
# ============================================================================
# ADVANCED SESSION MANAGER WITH READ REPLICAS
# ============================================================================
class DatabaseManager:
"""Продвинутый менеджер БД с поддержкой реплик и кеширования"""
def __init__(self):
self.engine = engine
self.read_replica_engine: Optional[AsyncEngine] = None
if hasattr(settings, 'DATABASE_READ_REPLICA_URL') and settings.DATABASE_READ_REPLICA_URL:
self.read_replica_engine = create_async_engine(
settings.DATABASE_READ_REPLICA_URL,
poolclass=poolclass,
pool_size=30, # Больше для read операций
max_overflow=50,
pool_pre_ping=True,
echo=False,
)
@asynccontextmanager
async def session(self, read_only: bool = False):
target_engine = self.read_replica_engine if (read_only and self.read_replica_engine) else self.engine
async_session = async_sessionmaker(
bind=target_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async with async_session() as session:
try:
yield session
if not read_only:
await session.commit()
except Exception:
await session.rollback()
raise
async def health_check(self) -> dict:
pool = self.engine.pool
try:
async with AsyncSessionLocal() as session:
start = time.time()
await session.execute(text("SELECT 1"))
latency = (time.time() - start) * 1000
status = "healthy"
except Exception as e:
logger.error(f"❌ Database health check failed: {e}")
status = "unhealthy"
latency = None
return {
"status": status,
"latency_ms": round(latency, 2) if latency else None,
"pool": _collect_health_pool_metrics(pool),
}
db_manager = DatabaseManager()
# ============================================================================
# SESSION DEPENDENCY FOR FASTAPI/AIOGRAM
# ============================================================================
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Стандартная dependency для FastAPI"""
async with AsyncSessionLocal() as session:
try:
yield session
@@ -33,19 +169,229 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def get_db_read_only() -> AsyncGenerator[AsyncSession, None]:
"""Read-only dependency для тяжелых SELECT запросов"""
async with db_manager.session(read_only=True) as session:
yield session
# ============================================================================
# BATCH OPERATIONS FOR PERFORMANCE
# ============================================================================
class BatchOperations:
"""Утилиты для массовых операций"""
@staticmethod
async def bulk_insert(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
"""Массовая вставка с чанками"""
for i in range(0, len(data), chunk_size):
chunk = data[i:i + chunk_size]
session.add_all([model(**item) for item in chunk])
await session.flush()
await session.commit()
@staticmethod
async def bulk_update(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
"""Массовое обновление с чанками"""
if not data:
return
primary_keys = [column.name for column in model.__table__.primary_key.columns]
if not primary_keys:
raise ValueError("Model must have a primary key for bulk_update")
updatable_columns = [
column.name
for column in model.__table__.columns
if column.name not in primary_keys
]
if not updatable_columns:
raise ValueError("No columns available for update in bulk_update")
stmt = (
model.__table__.update()
.where(
*[
getattr(model.__table__.c, pk) == bindparam(pk)
for pk in primary_keys
]
)
.values(
**{
column: bindparam(column, required=False)
for column in updatable_columns
}
)
)
for i in range(0, len(data), chunk_size):
chunk = data[i:i + chunk_size]
filtered_chunk = []
for item in chunk:
missing_keys = [pk for pk in primary_keys if pk not in item]
if missing_keys:
raise ValueError(
f"Missing primary key values {missing_keys} for bulk_update"
)
filtered_item = {
key: value
for key, value in item.items()
if key in primary_keys or key in updatable_columns
}
filtered_chunk.append(filtered_item)
await session.execute(stmt, filtered_chunk)
await session.commit()
batch_ops = BatchOperations()
# ============================================================================
# INITIALIZATION AND CLEANUP
# ============================================================================
async def init_db():
logger.info("Создание таблиц базы данных...")
"""Инициализация БД с оптимизациями"""
logger.info("🚀 Создание таблиц базы данных...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
if not settings.get_database_url().startswith("sqlite"):
logger.info("📊 Создание индексов для оптимизации...")
async with engine.begin() as conn:
indexes = [
("users", "CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)"),
(
"subscriptions",
"CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)",
),
(
"subscriptions",
"CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status) WHERE status = 'active'",
),
(
"payments",
"CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)",
),
]
for table_name, index_sql in indexes:
table_exists = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table(table_name))
if not table_exists:
logger.debug(
"Пропускаем создание индекса %s: таблица %s отсутствует",
index_sql,
table_name,
)
continue
try:
await conn.execute(text(index_sql))
except Exception as e:
logger.debug("Index creation skipped for %s: %s", table_name, e)
logger.info("✅ База данных успешно инициализирована")
health = await db_manager.health_check()
logger.info(f"📊 Database health: {health}")
async def close_db():
"""Корректное закрытие всех соединений"""
logger.info("🔄 Закрытие соединений с БД...")
await engine.dispose()
logger.info("✅ Подключение к базе данных закрыто")
if db_manager.read_replica_engine:
await db_manager.read_replica_engine.dispose()
logger.info("✅ Все подключения к базе данных закрыты")
# ============================================================================
# CONNECTION POOL METRICS (для мониторинга)
# ============================================================================
def _pool_counters(pool):
"""Return basic pool counters or ``None`` when unsupported."""
required_methods = ("size", "checkedin", "checkedout", "overflow")
for method_name in required_methods:
method = getattr(pool, method_name, None)
if method is None or not callable(method):
return None
size = pool.size()
checked_in = pool.checkedin()
checked_out = pool.checkedout()
overflow = pool.overflow()
total_connections = size + overflow
return {
"size": size,
"checked_in": checked_in,
"checked_out": checked_out,
"overflow": overflow,
"total_connections": total_connections,
"utilization_percent": (checked_out / total_connections * 100) if total_connections else 0.0,
}
def _collect_health_pool_metrics(pool) -> dict:
counters = _pool_counters(pool)
if counters is None:
return {
"metrics_available": False,
"size": 0,
"checked_in": 0,
"checked_out": 0,
"overflow": 0,
"total_connections": 0,
"utilization": "0.0%",
}
return {
"metrics_available": True,
"size": counters["size"],
"checked_in": counters["checked_in"],
"checked_out": counters["checked_out"],
"overflow": counters["overflow"],
"total_connections": counters["total_connections"],
"utilization": f"{counters['utilization_percent']:.1f}%",
}
async def get_pool_metrics() -> dict:
"""Детальные метрики пула для Prometheus/Grafana"""
pool = engine.pool
counters = _pool_counters(pool)
if counters is None:
return {
"metrics_available": False,
"pool_size": 0,
"checked_in_connections": 0,
"checked_out_connections": 0,
"overflow_connections": 0,
"total_connections": 0,
"max_possible_connections": 0,
"pool_utilization_percent": 0.0,
}
return {
"metrics_available": True,
"pool_size": counters["size"],
"checked_in_connections": counters["checked_in"],
"checked_out_connections": counters["checked_out"],
"overflow_connections": counters["overflow"],
"total_connections": counters["total_connections"],
"max_possible_connections": counters["total_connections"] + (getattr(pool, "_max_overflow", 0) or 0),
"pool_utilization_percent": round(counters["utilization_percent"], 2),
}
+31 -1
View File
@@ -2107,6 +2107,9 @@ async def ensure_promo_groups_setup():
addon_discount_column_exists = await check_column_exists(
"promo_groups", "apply_discounts_to_addons"
)
priority_column_exists = await check_column_exists(
"promo_groups", "priority"
)
if not addon_discount_column_exists:
if db_type == "sqlite":
@@ -2289,7 +2292,23 @@ async def ensure_promo_groups_setup():
"is_default": True,
}
if addon_discount_column_exists:
if priority_column_exists:
insert_params["priority"] = 0
if addon_discount_column_exists and priority_column_exists:
insert_sql = """
INSERT INTO promo_groups (
name,
priority,
server_discount_percent,
traffic_discount_percent,
device_discount_percent,
apply_discounts_to_addons,
is_default
) VALUES (:name, :priority, 0, 0, 0, :apply_discounts_to_addons, :is_default)
"""
insert_params["apply_discounts_to_addons"] = True
elif addon_discount_column_exists:
insert_sql = """
INSERT INTO promo_groups (
name,
@@ -2301,6 +2320,17 @@ async def ensure_promo_groups_setup():
) VALUES (:name, 0, 0, 0, :apply_discounts_to_addons, :is_default)
"""
insert_params["apply_discounts_to_addons"] = True
elif priority_column_exists:
insert_sql = """
INSERT INTO promo_groups (
name,
priority,
server_discount_percent,
traffic_discount_percent,
device_discount_percent,
is_default
) VALUES (:name, :priority, 0, 0, 0, :is_default)
"""
else:
insert_sql = """
INSERT INTO promo_groups (
+4 -4
View File
@@ -1,4 +1,4 @@
"""Flask webhook server for PayPalych postbacks."""
"""Flask webhook server for PayPalych callbacks."""
from __future__ import annotations
@@ -65,7 +65,7 @@ def create_pal24_flask_app(
return jsonify({"status": "error", "reason": "empty_payload"}), 400
try:
parsed_payload = pal24_service.parse_postback(payload)
parsed_payload = pal24_service.parse_callback(payload)
except Pal24APIError as error:
logger.error("Ошибка валидации Pal24 webhook: %s", error)
return jsonify({"status": "error", "reason": str(error)}), 400
@@ -73,7 +73,7 @@ def create_pal24_flask_app(
async def process() -> bool:
async with AsyncSessionLocal() as db:
try:
return await payment_service.process_pal24_postback(db, parsed_payload)
return await payment_service.process_pal24_callback(db, parsed_payload)
except Exception:
await db.rollback()
raise
@@ -112,7 +112,7 @@ def create_pal24_flask_app(
class Pal24WebhookServer:
"""Threaded Flask server for Pal24 postbacks."""
"""Threaded Flask server for Pal24 callbacks."""
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
self.app = create_pal24_flask_app(payment_service, loop)
+78 -33
View File
@@ -33,6 +33,32 @@ YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = (
)
CLOUDFLARE_TRUSTED_NETWORKS: tuple[IPNetwork, ...] = (
ip_network("173.245.48.0/20"),
ip_network("103.21.244.0/22"),
ip_network("103.22.200.0/22"),
ip_network("103.31.4.0/22"),
ip_network("141.101.64.0/18"),
ip_network("108.162.192.0/18"),
ip_network("190.93.240.0/20"),
ip_network("188.114.96.0/20"),
ip_network("197.234.240.0/22"),
ip_network("198.41.128.0/17"),
ip_network("162.158.0.0/15"),
ip_network("104.16.0.0/13"),
ip_network("104.24.0.0/14"),
ip_network("172.64.0.0/13"),
ip_network("131.0.72.0/22"),
ip_network("2400:cb00::/32"),
ip_network("2606:4700::/32"),
ip_network("2803:f800::/32"),
ip_network("2405:b500::/32"),
ip_network("2405:8100::/32"),
ip_network("2a06:98c0::/29"),
ip_network("2c0f:f248::/32"),
)
YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = (
"payment.succeeded",
"payment.waiting_for_capture",
@@ -122,6 +148,9 @@ def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool:
):
return True
if any(ip_object in network for network in CLOUDFLARE_TRUSTED_NETWORKS):
return True
return any(ip_object in network for network in _get_trusted_proxy_networks())
@@ -167,10 +196,10 @@ def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool:
class YooKassaWebhookHandler:
def __init__(self, payment_service: PaymentService):
self.payment_service = payment_service
async def handle_webhook(self, request: web.Request) -> web.Response:
try:
@@ -180,6 +209,7 @@ class YooKassaWebhookHandler:
header_ip_candidates = collect_yookassa_ip_candidates(
request.headers.get("X-Forwarded-For"),
request.headers.get("X-Real-IP"),
request.headers.get("Cf-Connecting-Ip"),
)
client_ip = resolve_yookassa_ip(
header_ip_candidates,
@@ -220,46 +250,61 @@ class YooKassaWebhookHandler:
except json.JSONDecodeError as e:
logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}")
return web.Response(status=400, text="Invalid JSON")
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
# Извлекаем ID платежа из вебхука для предотвращения дублирования
yookassa_payment_id = webhook_data.get("object", {}).get("id")
if not yookassa_payment_id:
logger.warning("⚠️ Webhook YooKassa без ID платежа")
return web.Response(status=400, text="No payment ID")
event_type = webhook_data.get("event")
if not event_type:
logger.warning("⚠️ Webhook YooKassa без типа события")
return web.Response(status=400, text="No event type")
if event_type not in YOOKASSA_ALLOWED_EVENTS:
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
return web.Response(status=200, text="OK")
async for db in get_db():
try:
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
from app.database.models import PaymentMethod
from app.database.crud.transaction import get_transaction_by_external_id
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA)
if existing_transaction and event_type == "payment.succeeded":
logger.info(f"️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
return web.Response(status=200, text="OK")
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
if success:
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
return web.Response(status=200, text="OK")
else:
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
return web.Response(status=500, text="Processing error")
finally:
await db.close()
except Exception as e:
logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
return web.Response(status=500, text="Internal server error")
def setup_routes(self, app: web.Application) -> None:
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
app.router.add_post(webhook_path, self.handle_webhook)
app.router.add_get(webhook_path, self._get_handler)
app.router.add_options(webhook_path, self._options_handler)
app.router.add_get(webhook_path, self._get_handler)
app.router.add_options(webhook_path, self._options_handler)
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
async def _get_handler(self, request: web.Request) -> web.Response:
return web.json_response({
"status": "ok",
@@ -268,7 +313,7 @@ class YooKassaWebhookHandler:
"path": request.path,
"note": "Use POST method for actual webhooks"
})
async def _options_handler(self, request: web.Request) -> web.Response:
return web.Response(
status=200,
@@ -281,46 +326,46 @@ class YooKassaWebhookHandler:
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
app = web.Application()
webhook_handler = YooKassaWebhookHandler(payment_service)
webhook_handler.setup_routes(app)
async def health_check(request):
return web.json_response({
"status": "ok",
"status": "ok",
"service": "yookassa_webhook",
"port": settings.YOOKASSA_WEBHOOK_PORT,
"path": settings.YOOKASSA_WEBHOOK_PATH,
"enabled": settings.is_yookassa_enabled()
})
app.router.add_get("/health", health_check)
return app
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_yookassa_enabled():
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
return
try:
app = create_yookassa_webhook_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(
runner,
host=settings.YOOKASSA_WEBHOOK_HOST,
port=settings.YOOKASSA_WEBHOOK_PORT
)
await site.start()
logger.info(
"✅ YooKassa webhook сервер запущен на %s:%s",
settings.YOOKASSA_WEBHOOK_HOST,
@@ -332,7 +377,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
settings.YOOKASSA_WEBHOOK_PORT,
settings.YOOKASSA_WEBHOOK_PATH,
)
try:
while True:
await asyncio.sleep(1)
@@ -342,7 +387,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
await site.stop()
await runner.cleanup()
logger.info("✅ YooKassa webhook сервер остановлен")
except Exception as e:
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
raise
raise
+120 -6
View File
@@ -1,5 +1,7 @@
import html
import logging
from typing import Any, Optional
from aiogram import types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
@@ -423,8 +425,77 @@ async def check_pal24_payment_status(
if not isinstance(links_meta, dict):
links_meta = {}
sbp_link = links_meta.get("sbp") or payment.link_url
card_link = links_meta.get("card")
links_info = status_info.get("links") or {}
def _extract_link(source: Any, keys: tuple[str, ...]) -> Optional[str]:
stack: list[Any] = [source]
while stack:
current = stack.pop()
if isinstance(current, dict):
for key in keys:
value = current.get(key)
if value:
return str(value)
stack.extend(current.values())
elif isinstance(current, list):
stack.extend(current)
return None
raw_response = metadata.get("raw_response") if isinstance(metadata, dict) else None
remote_data = status_info.get("remote_data")
transfer_keys = (
"transfer_url",
"transferUrl",
"transfer_link",
"transferLink",
"transfer",
"sbp_url",
"sbpUrl",
"sbp_link",
"sbpLink",
)
card_keys = (
"link_url",
"linkUrl",
"link",
"card_url",
"cardUrl",
"card_link",
"cardLink",
"payment_url",
"paymentUrl",
"url",
)
extra_sbp_link = (
_extract_link(raw_response, transfer_keys)
if raw_response
else None
)
if not extra_sbp_link and remote_data:
extra_sbp_link = _extract_link(remote_data, transfer_keys)
extra_card_link = (
_extract_link(raw_response, card_keys)
if raw_response
else None
)
if not extra_card_link and remote_data:
extra_card_link = _extract_link(remote_data, card_keys)
sbp_link = (
links_info.get("sbp")
or links_meta.get("sbp")
or status_info.get("sbp_url")
or extra_sbp_link
or payment.link_url
)
card_link = (
links_info.get("card")
or links_meta.get("card")
or status_info.get("card_url")
or extra_card_link
)
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
card_link = payment.link_page_url
@@ -459,10 +530,53 @@ async def check_pal24_payment_status(
db_user = getattr(callback, 'db_user', None)
texts = get_texts(db_user.language if db_user else 'ru') if db_user else get_texts('ru')
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), callback_data=f"check_pal24_{local_payment_id}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
pay_rows: list[list[types.InlineKeyboardButton]] = []
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
default_sbp_text = texts.t(
"PAL24_SBP_PAY_BUTTON",
"🏦 Оплатить через PayPalych (СБП)",
)
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
if sbp_link and settings.is_pal24_sbp_button_visible():
pay_rows.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=sbp_link,
)
]
)
default_card_text = texts.t(
"PAL24_CARD_PAY_BUTTON",
"💳 Оплатить банковской картой (PayPalych)",
)
card_button_text = settings.get_pal24_card_button_text(default_card_text)
if card_link and settings.is_pal24_card_button_visible():
if not pay_rows or pay_rows[-1][0].url != card_link:
pay_rows.append(
[
types.InlineKeyboardButton(
text=card_button_text,
url=card_link,
)
]
)
keyboard_rows = pay_rows + [
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_pal24_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.answer()
try:
+20 -2
View File
@@ -32,11 +32,25 @@ async def _prompt_amount(
texts = get_texts(db_user.language)
method_name = settings.get_platega_method_display_title(method_code)
min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
max_amount_label = (
settings.format_price(max_amount_kopeks)
if max_amount_kopeks and max_amount_kopeks > 0
else ""
)
default_prompt_body = (
"Введите сумму для пополнения от {min_amount} до {max_amount}.\n"
if max_amount_kopeks and max_amount_kopeks > 0
else "Введите сумму для пополнения от {min_amount}.\n"
)
prompt_template = texts.t(
"PLATEGA_TOPUP_PROMPT",
(
"💳 <b>Оплата через Platega ({method_name})</b>\n\n"
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
f"{default_prompt_body}"
"Оплата происходит через Platega."
),
)
@@ -51,7 +65,11 @@ async def _prompt_amount(
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await message.edit_text(
prompt_template.format(method_name=method_name),
prompt_template.format(
method_name=method_name,
min_amount=min_amount_label,
max_amount=max_amount_label,
),
reply_markup=keyboard,
parse_mode="HTML",
)
+50 -3
View File
@@ -1655,8 +1655,18 @@ async def check_simple_pal24_payment_status(
if not isinstance(links_meta, dict):
links_meta = {}
sbp_link = links_meta.get("sbp") or payment.link_url
card_link = links_meta.get("card")
links_info = status_info.get("links") or {}
sbp_link = (
links_info.get("sbp")
or links_meta.get("sbp")
or status_info.get("sbp_url")
or payment.link_url
)
card_link = (
links_info.get("card")
or links_meta.get("card")
or status_info.get("card_url")
)
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
card_link = payment.link_page_url
@@ -1689,8 +1699,45 @@ async def check_simple_pal24_payment_status(
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}",
]
pay_rows: list[list[types.InlineKeyboardButton]] = []
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
default_sbp_text = texts.t(
"PAL24_SBP_PAY_BUTTON",
"🏦 Оплатить через PayPalych (СБП)",
)
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
if sbp_link and settings.is_pal24_sbp_button_visible():
pay_rows.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=sbp_link,
)
]
)
default_card_text = texts.t(
"PAL24_CARD_PAY_BUTTON",
"💳 Оплатить банковской картой (PayPalych)",
)
card_button_text = settings.get_pal24_card_button_text(default_card_text)
if card_link and settings.is_pal24_card_button_visible():
if not pay_rows or pay_rows[-1][0].url != card_link:
pay_rows.append(
[
types.InlineKeyboardButton(
text=card_button_text,
url=card_link,
)
]
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
inline_keyboard=pay_rows
+ [
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
+8 -2
View File
@@ -503,7 +503,10 @@ async def activate_trial(
await callback.message.edit_text(
message,
reply_markup=get_insufficient_balance_keyboard(db_user.language),
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=error.required_amount,
),
)
await callback.answer()
return
@@ -565,7 +568,10 @@ async def activate_trial(
await callback.message.edit_text(
message,
reply_markup=get_insufficient_balance_keyboard(db_user.language),
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=error.required_amount,
),
)
await callback.answer()
return
+3 -1
View File
@@ -1061,6 +1061,8 @@
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
@@ -1079,7 +1081,7 @@
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
"PLATEGA_TOPUP_PROMPT": "💳 <b>Payment via Platega ({method_name})</b>\n\nEnter the amount from 100 to 1,000,000 ₽.\nPayment is processed by Platega.",
"PLATEGA_TOPUP_PROMPT": "💳 <b>Payment via Platega ({method_name})</b>\n\nEnter an amount from {min_amount} to {max_amount}.\nPayment is processed by Platega.",
"PLATEGA_SELECT_PAYMENT_METHOD": "Choose a Platega payment method:",
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Platega payments are temporarily unavailable",
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ No active Platega methods configured",
+3 -1
View File
@@ -1081,6 +1081,8 @@
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
@@ -1099,7 +1101,7 @@
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
"PLATEGA_TOPUP_PROMPT": "💳 <b>Оплата через Platega ({method_name})</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата происходит через Platega.",
"PLATEGA_TOPUP_PROMPT": "💳 <b>Оплата через Platega ({method_name})</b>\n\nВведите сумму для пополнения от {min_amount} до {max_amount}.\nОплата происходит через Platega.",
"PLATEGA_SELECT_PAYMENT_METHOD": "Выберите способ оплаты Platega:",
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Оплата через Platega временно недоступна",
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ На стороне Platega нет доступных методов оплаты",
File diff suppressed because it is too large Load Diff
+23 -5
View File
@@ -288,12 +288,30 @@ class MaintenanceService:
self._status.last_check = datetime.utcnow()
auth_params = settings.get_remnawave_auth_params()
base_url = (auth_params.get("base_url") or "").strip()
api_key = (auth_params.get("api_key") or "").strip()
secret_key = (auth_params.get("secret_key") or "").strip() or None
username = (auth_params.get("username") or "").strip() or None
password = (auth_params.get("password") or "").strip() or None
if not base_url:
logger.error("REMNAWAVE_API_URL не настроен, пропускаем проверку API")
self._status.api_status = False
self._status.consecutive_failures = 0
return False
if not api_key:
logger.error("REMNAWAVE_API_KEY не настроен, пропускаем проверку API")
self._status.api_status = False
self._status.consecutive_failures = 0
return False
api = RemnaWaveAPI(
base_url=auth_params["base_url"],
api_key=auth_params["api_key"],
secret_key=auth_params["secret_key"],
username=auth_params["username"],
password=auth_params["password"]
base_url=base_url,
api_key=api_key,
secret_key=secret_key,
username=username,
password=password
)
attempts = settings.get_maintenance_retry_attempts()
+4 -1
View File
@@ -937,7 +937,10 @@ class MonitoringService:
if not user:
continue
renewal_cost = settings.PRICE_30_DAYS
# Правильный расчет стоимости продления с учетом всех параметров подписки
renewal_cost = await self.subscription_service.calculate_renewal_price(
subscription, 30, db, user=user
)
promo_discount_percent = self._get_user_promo_offer_discount_percent(user)
charge_amount = renewal_cost
promo_discount_value = 0
+4 -4
View File
@@ -90,21 +90,21 @@ class Pal24Service:
return await self.client.get_bill_payments(bill_id)
@staticmethod
def parse_postback(payload: Dict[str, Any]) -> Dict[str, Any]:
def parse_callback(payload: Dict[str, Any]) -> Dict[str, Any]:
required_fields = ["InvId", "OutSum", "Status", "SignatureValue"]
missing = [field for field in required_fields if field not in payload]
if missing:
raise Pal24APIError(f"Pal24 postback missing fields: {', '.join(missing)}")
raise Pal24APIError(f"Pal24 callback missing fields: {', '.join(missing)}")
inv_id = str(payload["InvId"])
out_sum = str(payload["OutSum"])
signature = str(payload["SignatureValue"])
if not Pal24Client.verify_signature(out_sum, inv_id, signature):
raise Pal24APIError("Pal24 postback signature mismatch")
raise Pal24APIError("Pal24 callback signature mismatch")
logger.info(
"Получен Pal24 postback: InvId=%s, Status=%s, TrsId=%s",
"Получен Pal24 callback: InvId=%s, Status=%s, TrsId=%s",
inv_id,
payload.get("Status"),
payload.get("TrsId"),
+205 -69
View File
@@ -1,8 +1,9 @@
"""Mixin с логикой обработки платежей CryptoBot."""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional
@@ -10,6 +11,7 @@ from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
@@ -20,6 +22,33 @@ from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class _AdminNotificationContext:
user_id: int
transaction_id: int
old_balance: int
topup_status: str
referrer_info: str
@dataclass(slots=True)
class _UserNotificationPayload:
telegram_id: int
text: str
parse_mode: Optional[str]
reply_markup: Any
amount_rubles: float
asset: str
@dataclass(slots=True)
class _SavedCartNotificationPayload:
telegram_id: int
text: str
reply_markup: Any
user_id: int
class CryptoBotPaymentMixin:
"""Mixin, отвечающий за генерацию инвойсов CryptoBot и обработку webhook."""
@@ -149,14 +178,16 @@ class CryptoBotPaymentMixin:
try:
amount_rubles = await currency_converter.usd_to_rub(amount_usd)
amount_kopeks = int(amount_rubles * 100)
amount_rubles_rounded = math.ceil(amount_rubles)
amount_kopeks = int(amount_rubles_rounded * 100)
conversion_rate = (
amount_rubles / amount_usd if amount_usd > 0 else 0
)
logger.info(
"Конвертация USD->RUB: $%s -> %s₽ (курс: %.2f)",
"Конвертация USD->RUB: $%s -> %s₽ (округлено до %s₽, курс: %.2f)",
amount_usd,
amount_rubles,
amount_rubles_rounded,
conversion_rate,
)
except Exception as error:
@@ -166,7 +197,8 @@ class CryptoBotPaymentMixin:
error,
)
amount_rubles = amount_usd
amount_kopeks = int(amount_usd * 100)
amount_rubles_rounded = math.ceil(amount_rubles)
amount_kopeks = int(amount_rubles_rounded * 100)
conversion_rate = 1.0
if amount_kopeks <= 0:
@@ -185,7 +217,7 @@ class CryptoBotPaymentMixin:
amount_kopeks=amount_kopeks,
description=(
"Пополнение через CryptoBot "
f"({updated_payment.amount} {updated_payment.asset}{amount_rubles:.2f}₽)"
f"({updated_payment.amount} {updated_payment.asset}{amount_rubles_rounded:.2f}₽)"
),
payment_method=PaymentMethod.CRYPTOBOT,
external_id=invoice_id,
@@ -211,8 +243,6 @@ class CryptoBotPaymentMixin:
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
@@ -241,55 +271,41 @@ class CryptoBotPaymentMixin:
await db.refresh(user)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
admin_notification: Optional[_AdminNotificationContext] = None
user_notification: Optional[_UserNotificationPayload] = None
saved_cart_notification: Optional[_SavedCartNotificationPayload] = None
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
error,
)
bot_instance = getattr(self, "bot", None)
if bot_instance:
admin_notification = _AdminNotificationContext(
user_id=user.id,
transaction_id=transaction.id,
old_balance=old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
f"💱 Курс: 1 USD = {conversion_rate:.2f}\n"
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
"Баланс пополнен автоматически!"
),
message_text = (
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
f"💱 Курс: 1 USD = {conversion_rate:.2f}\n"
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
"Баланс пополнен автоматически!"
)
user_notification = _UserNotificationPayload(
telegram_id=user.telegram_id,
text=message_text,
parse_mode="HTML",
reply_markup=keyboard,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
user.telegram_id,
f"{amount_rubles:.2f}",
updated_payment.asset,
amount_rubles=amount_rubles_rounded,
asset=updated_payment.asset,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
"Ошибка подготовки уведомления о пополнении CryptoBot: %s",
error,
)
@@ -305,7 +321,7 @@ class CryptoBotPaymentMixin:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
bot=bot_instance,
)
except Exception as auto_error:
logger.error(
@@ -318,17 +334,14 @@ class CryptoBotPaymentMixin:
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
if has_saved_cart and bot_instance:
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
total_amount=settings.format_price(payment.amount_kopeks)
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
@@ -343,22 +356,35 @@ class CryptoBotPaymentMixin:
callback_data="back_to_menu"
)]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
reply_markup=keyboard
saved_cart_notification = _SavedCartNotificationPayload(
telegram_id=user.telegram_id,
text=(
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}"
),
reply_markup=keyboard,
user_id=user.id,
)
logger.info(
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
user.id,
)
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
except Exception as error:
logger.error(
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
user.id,
error,
exc_info=True,
)
if admin_notification:
await self._deliver_admin_topup_notification(admin_notification)
if user_notification and bot_instance:
await self._deliver_user_topup_notification(user_notification)
if saved_cart_notification and bot_instance:
await self._deliver_saved_cart_reminder(saved_cart_notification)
return True
@@ -368,6 +394,116 @@ class CryptoBotPaymentMixin:
)
return False
async def _deliver_admin_topup_notification(
self, context: _AdminNotificationContext
) -> None:
bot_instance = getattr(self, "bot", None)
if not bot_instance:
return
try:
from app.services.admin_notification_service import AdminNotificationService
from app.database.crud.user import get_user_by_id
from app.database.crud.transaction import get_transaction_by_id
except Exception as error:
logger.error(
"Не удалось импортировать зависимости для админ-уведомления CryptoBot: %s",
error,
exc_info=True,
)
return
async with AsyncSessionLocal() as session:
try:
user = await get_user_by_id(session, context.user_id)
transaction = await get_transaction_by_id(session, context.transaction_id)
except Exception as error:
logger.error(
"Ошибка загрузки данных для админ-уведомления CryptoBot: %s",
error,
exc_info=True,
)
await session.rollback()
return
if not user or not transaction:
logger.warning(
"Пропущена отправка админ-уведомления CryptoBot: user=%s transaction=%s",
bool(user),
bool(transaction),
)
return
notification_service = AdminNotificationService(bot_instance)
try:
await notification_service.send_balance_topup_notification(
user,
transaction,
context.old_balance,
topup_status=context.topup_status,
referrer_info=context.referrer_info,
subscription=getattr(user, "subscription", None),
promo_group=getattr(user, "promo_group", None),
db=session,
)
except Exception as error:
logger.error(
"Ошибка отправки админ-уведомления о пополнении CryptoBot: %s",
error,
exc_info=True,
)
async def _deliver_user_topup_notification(
self, payload: _UserNotificationPayload
) -> None:
bot_instance = getattr(self, "bot", None)
if not bot_instance:
return
try:
await bot_instance.send_message(
payload.telegram_id,
payload.text,
parse_mode=payload.parse_mode,
reply_markup=payload.reply_markup,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
payload.telegram_id,
f"{payload.amount_rubles:.2f}",
payload.asset,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
error,
)
async def _deliver_saved_cart_reminder(
self, payload: _SavedCartNotificationPayload
) -> None:
bot_instance = getattr(self, "bot", None)
if not bot_instance:
return
try:
await bot_instance.send_message(
chat_id=payload.telegram_id,
text=payload.text,
reply_markup=payload.reply_markup,
)
logger.info(
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
payload.user_id,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о сохраненной корзине для пользователя %s: %s",
payload.user_id,
error,
exc_info=True,
)
async def get_cryptobot_payment_status(
self,
db: AsyncSession,
+178 -42
View File
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
class Pal24PaymentMixin:
"""Mixin с созданием счетов Pal24, обработкой postback и запросом статуса."""
"""Mixin с созданием счетов Pal24, обработкой callback и запросом статуса."""
async def create_pal24_payment(
self,
@@ -197,12 +197,12 @@ class Pal24PaymentMixin:
"payment_url": primary_link,
}
async def process_pal24_postback(
async def process_pal24_callback(
self,
db: AsyncSession,
postback: Dict[str, Any],
callback: Dict[str, Any],
) -> bool:
"""Обрабатывает postback от Pal24 и начисляет баланс при успехе."""
"""Обрабатывает callback от Pal24 и начисляет баланс при успехе."""
try:
payment_module = import_module("app.services.payment_service")
@@ -213,26 +213,26 @@ class Pal24PaymentMixin:
return None
payment_id = _first_non_empty(
postback.get("id"),
postback.get("TrsId"),
postback.get("TrsID"),
callback.get("id"),
callback.get("TrsId"),
callback.get("TrsID"),
)
bill_id = _first_non_empty(
postback.get("bill_id"),
postback.get("billId"),
postback.get("BillId"),
postback.get("BillID"),
callback.get("bill_id"),
callback.get("billId"),
callback.get("BillId"),
callback.get("BillID"),
)
order_id = _first_non_empty(
postback.get("order_id"),
postback.get("orderId"),
postback.get("InvId"),
postback.get("InvID"),
callback.get("order_id"),
callback.get("orderId"),
callback.get("InvId"),
callback.get("InvID"),
)
status = (postback.get("status") or postback.get("Status") or "").upper()
status = (callback.get("status") or callback.get("Status") or "").upper()
if not bill_id and not order_id:
logger.error("Pal24 postback без идентификаторов: %s", postback)
logger.error("Pal24 callback без идентификаторов: %s", callback)
return False
payment = None
@@ -250,57 +250,65 @@ class Pal24PaymentMixin:
return True
if status in {"PAID", "SUCCESS", "OVERPAID"}:
metadata = getattr(payment, "metadata_json", {}) or {}
if not isinstance(metadata, dict):
metadata = {}
payment = await payment_module.update_pal24_payment_status(
db,
payment,
status=status,
is_paid=True,
paid_at=datetime.utcnow(),
postback_payload=postback,
callback_payload=callback,
payment_id=payment_id,
payment_status=postback.get("Status") or status,
payment_status=callback.get("Status") or status,
payment_method=(
postback.get("payment_method")
or postback.get("PaymentMethod")
or (payment.metadata_json or {}).get("selected_method")
callback.get("payment_method")
or callback.get("PaymentMethod")
or metadata.get("selected_method")
or getattr(payment, "payment_method", None)
),
balance_amount=postback.get("BalanceAmount")
or postback.get("balance_amount"),
balance_currency=postback.get("BalanceCurrency")
or postback.get("balance_currency"),
payer_account=postback.get("AccountNumber")
or postback.get("account")
or postback.get("Account"),
balance_amount=callback.get("BalanceAmount")
or callback.get("balance_amount"),
balance_currency=callback.get("BalanceCurrency")
or callback.get("balance_currency"),
payer_account=callback.get("AccountNumber")
or callback.get("account")
or callback.get("Account"),
)
return await self._finalize_pal24_payment(
db,
payment,
payment_id=payment_id,
trigger="postback",
trigger="callback",
)
metadata = getattr(payment, "metadata_json", {}) or {}
if not isinstance(metadata, dict):
metadata = {}
await payment_module.update_pal24_payment_status(
db,
payment,
status=status or "UNKNOWN",
is_paid=False,
postback_payload=postback,
callback_payload=callback,
payment_id=payment_id,
payment_status=postback.get("Status") or status,
payment_status=callback.get("Status") or status,
payment_method=(
postback.get("payment_method")
or postback.get("PaymentMethod")
callback.get("payment_method")
or callback.get("PaymentMethod")
or getattr(payment, "payment_method", None)
),
balance_amount=postback.get("BalanceAmount")
or postback.get("balance_amount"),
balance_currency=postback.get("BalanceCurrency")
or postback.get("balance_currency"),
payer_account=postback.get("AccountNumber")
or postback.get("account")
or postback.get("Account"),
balance_amount=callback.get("BalanceAmount")
or callback.get("balance_amount"),
balance_currency=callback.get("BalanceCurrency")
or callback.get("balance_currency"),
payer_account=callback.get("AccountNumber")
or callback.get("account")
or callback.get("Account"),
)
logger.info(
"Обновили Pal24 платеж %s до статуса %s",
@@ -310,7 +318,7 @@ class Pal24PaymentMixin:
return True
except Exception as error:
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
logger.error("Ошибка обработки Pal24 callback: %s", error, exc_info=True)
return False
async def _finalize_pal24_payment(
@@ -685,12 +693,34 @@ class Pal24PaymentMixin:
exc_info=True,
)
links_map, selected_method = self._build_links_map(payment, remote_payloads)
primary_url = (
links_map.get(selected_method)
or links_map.get("sbp")
or links_map.get("page")
or links_map.get("card")
)
secondary_url = (
links_map.get("page")
or links_map.get("card")
or links_map.get("sbp")
)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote_status": remote_status_for_return,
"remote_data": remote_data,
"links": links_map or None,
"primary_url": primary_url,
"secondary_url": secondary_url,
"sbp_url": links_map.get("sbp"),
"card_url": links_map.get("card"),
"link_page_url": links_map.get("page")
or getattr(payment, "link_page_url", None),
"link_url": getattr(payment, "link_url", None),
"selected_method": selected_method,
}
except Exception as error:
@@ -841,6 +871,112 @@ class Pal24PaymentMixin:
normalized = payment_method.strip().lower()
return mapping.get(normalized, "sbp")
@staticmethod
def _pick_first(mapping: Dict[str, Any], *keys: str) -> Optional[str]:
for key in keys:
value = mapping.get(key)
if value:
return str(value)
return None
@classmethod
def _build_links_map(
cls,
payment: Any,
remote_payloads: Dict[str, Any],
) -> tuple[Dict[str, str], str]:
links: Dict[str, str] = {}
metadata = getattr(payment, "metadata_json", {}) or {}
if not isinstance(metadata, dict):
metadata = {}
if metadata:
links_meta = metadata.get("links")
if isinstance(links_meta, dict):
for key, value in links_meta.items():
if value:
links[key] = str(value)
selected_method = cls._normalize_payment_method(
(metadata.get("selected_method") if isinstance(metadata, dict) else None)
or getattr(payment, "payment_method", None)
)
def _visit(value: Any) -> List[Dict[str, Any]]:
stack: List[Any] = [value]
result: List[Dict[str, Any]] = []
while stack:
current = stack.pop()
if isinstance(current, dict):
result.append(current)
stack.extend(current.values())
elif isinstance(current, list):
stack.extend(current)
return result
payload_sources: List[Any] = []
if metadata:
payload_sources.append(metadata.get("raw_response"))
payload_sources.append(getattr(payment, "callback_payload", None))
payload_sources.extend(remote_payloads.values())
sbp_keys = (
"transfer_url",
"transferUrl",
"transfer_link",
"transferLink",
"transfer",
"sbp_url",
"sbpUrl",
"sbp_link",
"sbpLink",
)
card_keys = (
"link_url",
"linkUrl",
"link",
"card_url",
"cardUrl",
"card_link",
"cardLink",
"payment_url",
"paymentUrl",
"url",
)
page_keys = (
"link_page_url",
"linkPageUrl",
"page_url",
"pageUrl",
)
for source in payload_sources:
if not source:
continue
for candidate in _visit(source):
sbp_url = cls._pick_first(candidate, *sbp_keys)
if sbp_url and "sbp" not in links:
links["sbp"] = sbp_url
card_url = cls._pick_first(candidate, *card_keys)
if card_url and "card" not in links:
links["card"] = card_url
page_url = cls._pick_first(candidate, *page_keys)
if page_url and "page" not in links:
links["page"] = page_url
if getattr(payment, "link_page_url", None):
links.setdefault("page", str(payment.link_page_url))
if getattr(payment, "link_url", None):
link_url_value = str(payment.link_url)
if selected_method == "card":
links.setdefault("card", link_url_value)
else:
links.setdefault("sbp", link_url_value)
return links, selected_method
@staticmethod
def _map_api_payment_method(normalized_payment_method: str) -> Optional[str]:
"""Преобразует нормализованный метод оплаты в значение для Pal24 API."""
+216 -20
View File
@@ -382,7 +382,47 @@ class YooKassaPaymentMixin:
from sqlalchemy import select
payment_module = import_module("app.services.payment_service")
payment_description = getattr(payment, "description", "YooKassa платеж")
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
PaymentMethod.YOOKASSA,
)
if existing_transaction:
# Если транзакция уже существует, просто завершаем обработку
logger.info(
"Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.",
payment.yookassa_payment_id,
existing_transaction.id,
)
# Убедимся, что платеж связан с транзакцией
if not getattr(payment, "transaction_id", None):
try:
linked_payment = await payment_module.link_yookassa_payment_to_transaction( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
existing_transaction.id,
)
if linked_payment:
payment.transaction_id = getattr(
linked_payment,
"transaction_id",
existing_transaction.id,
)
if hasattr(linked_payment, "transaction"):
payment.transaction = linked_payment.transaction
except Exception as link_error: # pragma: no cover - защитный лог
logger.warning(
"Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s",
payment.yookassa_payment_id,
existing_transaction.id,
link_error,
exc_info=True,
)
return True
payment_metadata: Dict[str, Any] = {}
try:
@@ -397,6 +437,84 @@ class YooKassaPaymentMixin:
except Exception as parse_error:
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
processing_completed = bool(payment_metadata.get("processing_completed"))
transaction = None
existing_transaction_id = getattr(payment, "transaction_id", None)
if existing_transaction_id:
try:
from app.database.crud.transaction import get_transaction_by_id
transaction = await get_transaction_by_id(db, existing_transaction_id)
except Exception as fetch_error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось получить транзакцию %s для платежа YooKassa %s: %s",
existing_transaction_id,
payment.yookassa_payment_id,
fetch_error,
exc_info=True,
)
if transaction and processing_completed:
logger.info(
"Пропускаем повторную обработку платежа YooKassa %s: транзакция %s уже завершила начисление.",
payment.yookassa_payment_id,
existing_transaction_id,
)
return True
if transaction:
logger.info(
"Транзакция %s для платежа YooKassa %s найдена, но обработка ранее не была завершена — повторяем критические шаги.",
existing_transaction_id,
payment.yookassa_payment_id,
)
if transaction is None:
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
PaymentMethod.YOOKASSA,
)
if existing_transaction:
# Если транзакция уже существует, пропускаем обработку
logger.info(
"Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.",
payment.yookassa_payment_id,
existing_transaction.id,
)
# Убедимся, что платеж связан с транзакцией
if not getattr(payment, "transaction_id", None):
try:
linked_payment = await payment_module.link_yookassa_payment_to_transaction( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
existing_transaction.id,
)
if linked_payment:
payment.transaction_id = getattr(
linked_payment,
"transaction_id",
existing_transaction.id,
)
if hasattr(linked_payment, "transaction"):
payment.transaction = linked_payment.transaction
except Exception as link_error: # pragma: no cover - защитный лог
logger.warning(
"Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s",
payment.yookassa_payment_id,
existing_transaction.id,
link_error,
exc_info=True,
)
return True
payment_description = getattr(payment, "description", "YooKassa платеж")
payment_purpose = payment_metadata.get("payment_purpose", "")
is_simple_subscription = payment_purpose == "simple_subscription_purchase"
@@ -411,27 +529,32 @@ class YooKassaPaymentMixin:
else f"Пополнение через YooKassa: {payment_description}"
)
transaction = await payment_module.create_transaction(
db=db,
user_id=payment.user_id,
type=transaction_type,
amount_kopeks=payment.amount_kopeks,
description=transaction_description,
payment_method=PaymentMethod.YOOKASSA,
external_id=payment.yookassa_payment_id,
is_completed=True,
)
if transaction is None:
transaction = await payment_module.create_transaction(
db=db,
user_id=payment.user_id,
type=transaction_type,
amount_kopeks=payment.amount_kopeks,
description=transaction_description,
payment_method=PaymentMethod.YOOKASSA,
external_id=payment.yookassa_payment_id,
is_completed=True,
)
linked_payment = await payment_module.link_yookassa_payment_to_transaction(
db,
payment.yookassa_payment_id,
transaction.id,
)
if not getattr(payment, "transaction_id", None):
linked_payment = await payment_module.link_yookassa_payment_to_transaction(
db,
payment.yookassa_payment_id,
transaction.id,
)
if linked_payment:
payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id)
if hasattr(linked_payment, "transaction"):
payment.transaction = linked_payment.transaction
if linked_payment:
payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id)
if hasattr(linked_payment, "transaction"):
payment.transaction = linked_payment.transaction
critical_flow_completed = False
processing_marked = False
user = await payment_module.get_user_by_id(db, payment.user_id)
if user:
@@ -473,6 +596,14 @@ class YooKassaPaymentMixin:
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
)
payment_metadata = await self._mark_yookassa_payment_processing_completed(
db,
payment,
payment_metadata,
commit=False,
)
processing_marked = True
await db.commit()
try:
@@ -748,6 +879,31 @@ class YooKassaPaymentMixin:
except Exception as e:
logger.error(f"Ошибка активации подписки для пользователя {user.id}: {e}", exc_info=True)
if not processing_marked:
payment_metadata = await self._mark_yookassa_payment_processing_completed(
db,
payment,
payment_metadata,
commit=True,
)
processing_marked = True
critical_flow_completed = True
else:
logger.warning(
"Пользователь %s для платежа YooKassa %s не найден — начисление баланса невозможно",
payment.user_id,
payment.yookassa_payment_id,
)
if critical_flow_completed and not processing_marked:
payment_metadata = await self._mark_yookassa_payment_processing_completed(
db,
payment,
payment_metadata,
commit=True,
)
if is_simple_subscription:
logger.info(
"Успешно обработан платеж YooKassa %s как покупка подписки: пользователь %s, сумма %s",
@@ -773,6 +929,46 @@ class YooKassaPaymentMixin:
)
return False
async def _mark_yookassa_payment_processing_completed(
self,
db: AsyncSession,
payment: "YooKassaPayment",
payment_metadata: Dict[str, Any],
*,
commit: bool = False,
) -> Dict[str, Any]:
"""Отмечает платёж как полностью обработанный, чтобы избежать повторного начисления."""
if payment_metadata.get("processing_completed"):
return payment_metadata
updated_metadata = dict(payment_metadata)
updated_metadata["processing_completed"] = True
try:
from sqlalchemy import update
from app.database.models import YooKassaPayment as YooKassaPaymentModel
await db.execute(
update(YooKassaPaymentModel)
.where(YooKassaPaymentModel.id == payment.id)
.values(metadata_json=updated_metadata, updated_at=datetime.utcnow())
)
if commit:
await db.commit()
else:
await db.flush()
payment.metadata_json = updated_metadata
except Exception as mark_error: # pragma: no cover - защитный лог
logger.warning(
"Не удалось отметить платеж YooKassa %s как завершенный: %s",
payment.yookassa_payment_id,
mark_error,
exc_info=True,
)
return updated_metadata
async def process_yookassa_webhook(
self,
db: AsyncSession,
+15 -1
View File
@@ -90,7 +90,16 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
"description": "через Heleket",
"callback": "topup_heleket"
})
if settings.is_platega_enabled() and settings.get_platega_active_methods():
methods.append({
"id": "platega",
"name": "Банковская карта",
"icon": "💳",
"description": "через Platega (карты + СБП)",
"callback": "topup_platega",
})
# Поддержка всегда доступна
methods.append({
"id": "support",
@@ -174,6 +183,8 @@ def is_payment_method_available(method_id: str) -> bool:
return settings.is_cryptobot_enabled()
elif method_id == "heleket":
return settings.is_heleket_enabled()
elif method_id == "platega":
return settings.is_platega_enabled() and bool(settings.get_platega_active_methods())
elif method_id == "support":
return True # Поддержка всегда доступна
else:
@@ -192,6 +203,7 @@ def get_payment_method_status() -> Dict[str, bool]:
"pal24": settings.is_pal24_enabled(),
"cryptobot": settings.is_cryptobot_enabled(),
"heleket": settings.is_heleket_enabled(),
"platega": settings.is_platega_enabled() and bool(settings.get_platega_active_methods()),
"support": True
}
@@ -216,4 +228,6 @@ def get_enabled_payment_methods_count() -> int:
count += 1
if settings.is_heleket_enabled():
count += 1
if settings.is_platega_enabled() and settings.get_platega_active_methods():
count += 1
return count
+15
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, Security
from app.config import settings
from app.database import db_manager, get_pool_metrics
from app.services.version_service import version_service
from ..dependencies import require_api_token
@@ -24,3 +25,17 @@ async def health_check(_: object = Security(require_api_token)) -> HealthCheckRe
webhooks=bool(settings.WEBHOOK_URL),
),
)
@router.get("/health/database", tags=["health"])
async def database_health(_: object = Security(require_api_token)) -> dict:
"""Детальная информация о состоянии базы данных."""
return await db_manager.health_check()
@router.get("/metrics/pool", tags=["health"])
async def pool_metrics(_: object = Security(require_api_token)) -> dict:
"""Метрики пула подключений к базе данных."""
return await get_pool_metrics()
+10
View File
@@ -1537,6 +1537,8 @@ async def _resolve_pal24_payment_status(
if remote_status:
message = f"Status: {remote_status}"
links_info = status_info.get("links") if status_info else {}
return MiniAppPaymentStatusResult(
method="pal24",
status=status,
@@ -1556,6 +1558,14 @@ async def _resolve_pal24_payment_status(
"payment_method": getattr(payment, "payment_method", None),
"payload": query.payload,
"started_at": query.started_at,
"links": links_info or None,
"sbp_url": status_info.get("sbp_url") if status_info else None,
"card_url": status_info.get("card_url") if status_info else None,
"link_url": status_info.get("link_url") if status_info else None,
"link_page_url": status_info.get("link_page_url") if status_info else None,
"primary_url": status_info.get("primary_url") if status_info else None,
"secondary_url": status_info.get("secondary_url") if status_info else None,
"selected_method": status_info.get("selected_method") if status_info else None,
},
)
+3 -2
View File
@@ -349,6 +349,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
header_ip_candidates = yookassa_webhook_module.collect_yookassa_ip_candidates(
request.headers.get("X-Forwarded-For"),
request.headers.get("X-Real-IP"),
request.headers.get("Cf-Connecting-Ip"),
)
remote_ip = request.client.host if request.client else None
client_ip = yookassa_webhook_module.resolve_yookassa_ip(
@@ -578,7 +579,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
)
try:
parsed_payload = pal24_service.parse_postback(payload)
parsed_payload = pal24_service.parse_callback(payload)
except Pal24APIError as error:
return JSONResponse(
{"status": "error", "reason": str(error)},
@@ -588,7 +589,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
success = await _process_payment_service_callback(
payment_service,
parsed_payload,
"process_pal24_postback",
"process_pal24_callback",
)
if success:
return JSONResponse({"status": "ok"})
+3 -3
View File
@@ -170,8 +170,8 @@
- `app/external/pal24_client.py` — Async client for PayPalych (Pal24) API.
Классы: `Pal24APIError` — Base error for Pal24 API operations., `Pal24Response` (2 методов) — Wrapper for Pal24 API responses., `Pal24Client` (5 методов) — Async client implementing PayPalych API methods.
Функции: нет
- `app/external/pal24_webhook.py` — Flask webhook server for PayPalych postbacks.
Классы: `Pal24WebhookServer` (3 методов) — Threaded Flask server for Pal24 postbacks.
- `app/external/pal24_webhook.py` — Flask webhook server for PayPalych callbacks.
Классы: `Pal24WebhookServer` (3 методов) — Threaded Flask server for Pal24 callbacks.
Функции: `_normalize_payload`, `create_pal24_flask_app`
- `app/external/remnawave_api.py` — Python-модуль
Классы: `UserStatus`, `TrafficLimitStrategy`, `RemnaWaveUser`, `RemnaWaveInternalSquad`, `RemnaWaveNode`, `SubscriptionInfo`, `RemnaWaveAPIError` (1 методов), `RemnaWaveAPI` (8 методов)
@@ -731,7 +731,7 @@
Функции: `anyio_backend`, `_enable_service`, `test_is_configured`, `test_format_and_signature`
- `tests/services/test_pal24_service_adapter.py` — Тесты Pal24Service и вспомогательных функций.
Классы: `StubPal24Client` (1 методов)
Функции: `_enable_pal24`, `anyio_backend`, `test_parse_postback_success`, `test_parse_postback_missing_fields`, `test_convert_to_kopeks_and_expiration`
Функции: `_enable_pal24`, `anyio_backend`, `test_parse_callback_success`, `test_parse_callback_missing_fields`, `test_convert_to_kopeks_and_expiration`
- `tests/services/test_payment_service_cryptobot.py` — Тесты сценариев CryptoBot в PaymentService.
Классы: `DummySession` (2 методов), `DummyLocalPayment` (1 методов), `StubCryptoBotService` (1 методов)
Функции: `anyio_backend`, `_make_service`
+1
View File
@@ -14,6 +14,7 @@ redis==5.0.1
PyYAML==6.0.2
fastapi==0.115.6
uvicorn==0.32.1
python-multipart==0.0.9
# YooKassa SDK
yookassa==3.7.0
+27
View File
@@ -32,6 +32,7 @@ def _build_headers(**overrides: str) -> dict[str, str]:
headers = {
"Content-Type": "application/json",
"X-Forwarded-For": ALLOWED_IP,
"Cf-Connecting-Ip": ALLOWED_IP,
}
headers.update(overrides)
return headers
@@ -138,6 +139,32 @@ async def test_handle_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
process_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_handle_webhook_trusts_cf_connecting_ip(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_get_db(monkeypatch)
process_mock = AsyncMock(return_value=True)
service = SimpleNamespace(process_yookassa_webhook=process_mock)
app = create_yookassa_webhook_app(service)
async with TestClient(TestServer(app)) as client:
payload = {"event": "payment.succeeded"}
body = json.dumps(payload, ensure_ascii=False)
headers = _build_headers()
headers.pop("X-Forwarded-For")
response = await client.post(
settings.YOOKASSA_WEBHOOK_PATH,
data=body.encode("utf-8"),
headers=headers,
)
status = response.status
text = await response.text()
assert status == 200
assert text == "OK"
process_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_handle_webhook_with_optional_signature(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_get_db(monkeypatch)
+4 -4
View File
@@ -110,7 +110,7 @@ async def test_get_bill_payments(monkeypatch: pytest.MonkeyPatch) -> None:
assert result == {"id": "BILL42", "payments": [{"id": "PAY-1"}]}
def test_parse_postback_success(monkeypatch: pytest.MonkeyPatch) -> None:
def test_parse_callback_success(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_pal24(monkeypatch)
sig = Pal24Client.calculate_signature("100.00", "INV1", api_token="sigsecret")
payload = {
@@ -119,14 +119,14 @@ def test_parse_postback_success(monkeypatch: pytest.MonkeyPatch) -> None:
"Status": "SUCCESS",
"SignatureValue": sig,
}
result = Pal24Service.parse_postback(payload)
result = Pal24Service.parse_callback(payload)
assert result["InvId"] == "INV1"
def test_parse_postback_missing_fields(monkeypatch: pytest.MonkeyPatch) -> None:
def test_parse_callback_missing_fields(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_pal24(monkeypatch)
with pytest.raises(Pal24APIError):
Pal24Service.parse_postback({"InvId": "1"})
Pal24Service.parse_callback({"InvId": "1"})
def test_convert_to_kopeks_and_expiration() -> None:
@@ -17,6 +17,7 @@ if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
import app.services.payment_service as payment_service_module # noqa: E402
import app.services.payment.cryptobot as cryptobot_module # noqa: E402
from app.services.payment_service import PaymentService # noqa: E402
from app.database.models import PaymentMethod # noqa: E402
from app.config import settings # noqa: E402
@@ -283,13 +284,21 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
monkeypatch.setitem(sys.modules, "app.database.crud.cryptobot", fake_cryptobot_module)
transactions: list[Dict[str, Any]] = []
created_transaction: SimpleNamespace | None = None
async def fake_create_transaction(db, **kwargs):
nonlocal created_transaction
transactions.append(kwargs)
return SimpleNamespace(id=888, **kwargs)
created_transaction = SimpleNamespace(id=888, **kwargs)
return created_transaction
fake_transaction_module = ModuleType("app.database.crud.transaction")
fake_transaction_module.create_transaction = fake_create_transaction
async def fake_get_transaction_by_id(db, transaction_id):
return created_transaction
fake_transaction_module.get_transaction_by_id = fake_get_transaction_by_id
monkeypatch.setitem(sys.modules, "app.database.crud.transaction", fake_transaction_module)
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
@@ -310,6 +319,10 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user_crypto)
fake_user_module = ModuleType("app.database.crud.user")
fake_user_module.get_user_by_id = fake_get_user_crypto
monkeypatch.setitem(sys.modules, "app.database.crud.user", fake_user_module)
referral_crypto = SimpleNamespace(process_referral_topup=AsyncMock())
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_crypto)
@@ -323,6 +336,18 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
admin_calls.append((args, kwargs))
monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService2(bot)))
class DummyAsyncSession:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def rollback(self): # pragma: no cover - defensive stub
return None
monkeypatch.setattr(cryptobot_module, "AsyncSessionLocal", lambda: DummyAsyncSession())
monkeypatch.setattr(payment_service_module.currency_converter, "usd_to_rub", AsyncMock(return_value=140.0))
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}", raising=False)
service.build_topup_success_keyboard = AsyncMock(return_value=None)
@@ -921,7 +946,7 @@ async def test_process_yookassa_webhook_missing_id(monkeypatch: pytest.MonkeyPat
@pytest.mark.anyio("asyncio")
async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -> None:
async def test_process_pal24_callback_success(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
service = _make_service(bot)
service.pal24_service = SimpleNamespace(is_configured=True)
@@ -1044,7 +1069,7 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
"TrsId": "trs-1",
}
result = await service.process_pal24_postback(fake_session, payload)
result = await service.process_pal24_callback(fake_session, payload)
assert result is True
assert payment.transaction_id == 654
@@ -1209,7 +1234,7 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey
assert transactions and transactions[0]["user_id"] == 91
@pytest.mark.anyio("asyncio")
async def test_process_pal24_postback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
async def test_process_pal24_callback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
service = _make_service(bot)
service.pal24_service = SimpleNamespace(is_configured=True)
@@ -1236,5 +1261,5 @@ async def test_process_pal24_postback_payment_not_found(monkeypatch: pytest.Monk
"Status": "SUCCESS",
}
result = await service.process_pal24_postback(db, payload)
result = await service.process_pal24_callback(db, payload)
assert result is False
+68
View File
@@ -0,0 +1,68 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.handlers.subscription.purchase import activate_trial
from app.services.trial_activation_service import TrialPaymentInsufficientFunds
@pytest.fixture
def trial_callback_query():
callback = AsyncMock(spec=CallbackQuery)
callback.message = AsyncMock(spec=Message)
callback.message.edit_text = AsyncMock()
callback.answer = AsyncMock()
return callback
@pytest.fixture
def trial_user():
user = MagicMock(spec=User)
user.subscription = None
user.has_had_paid_subscription = False
user.language = "ru"
return user
@pytest.fixture
def trial_db():
return AsyncMock(spec=AsyncSession)
@pytest.mark.asyncio
async def test_activate_trial_uses_trial_price_for_topup_redirect(
trial_callback_query,
trial_user,
trial_db,
):
error = TrialPaymentInsufficientFunds(required_amount=15900, balance_amount=100)
mock_keyboard = InlineKeyboardMarkup(inline_keyboard=[])
with (
patch(
"app.handlers.subscription.purchase.preview_trial_activation_charge",
side_effect=error,
),
patch(
"app.handlers.subscription.purchase.get_texts",
return_value=MagicMock(
t=lambda key, default, **kwargs: default,
),
),
patch(
"app.handlers.subscription.purchase.get_insufficient_balance_keyboard",
return_value=mock_keyboard,
) as insufficient_keyboard,
):
await activate_trial(trial_callback_query, trial_user, trial_db)
insufficient_keyboard.assert_called_once_with(
trial_user.language,
amount_kopeks=error.required_amount,
)
trial_callback_query.message.edit_text.assert_called_once()
trial_callback_query.answer.assert_called_once()
+31
View File
@@ -263,6 +263,37 @@ async def test_yookassa_allowed_via_forwarded_header_when_proxy(monkeypatch: pyt
process_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_yookassa_allowed_via_cf_connecting_ip(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
async def fake_get_db():
yield SimpleNamespace()
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
process_mock = AsyncMock(return_value=True)
service = SimpleNamespace(process_yookassa_webhook=process_mock)
router = create_payment_router(DummyBot(), service)
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
headers={"Cf-Connecting-Ip": "185.71.76.10"},
client_ip="172.64.223.133",
)
response = await route.endpoint(request)
assert response.status_code == 200
payload = json.loads(response.body.decode("utf-8"))
assert payload["status"] == "ok"
process_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_yookassa_allowed_via_trusted_forwarded_chain(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)