Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14e24a546e | |||
| f60231e104 | |||
| 920bab5b4f | |||
| 68d2350dfd | |||
| 7d512d214a | |||
| 2cde38c63b | |||
| 24dc8d2a5e | |||
| 1522d35f2d | |||
| 2b0d8a2a88 | |||
| 9217f4116f | |||
| 2e45a93bd7 | |||
| d77fd81e16 | |||
| bdb8cab1c9 | |||
| d394565fe9 | |||
| 122d12db20 | |||
| 2071a680d3 | |||
| e4b4a54797 | |||
| 59c54c9b39 | |||
| 0d2b1dfdc9 | |||
| 605f202191 | |||
| be787a85bf | |||
| ff41ea9abb | |||
| cfbcc3082f | |||
| 2ad893badf | |||
| e78177b2fc | |||
| daa472570c | |||
| db7b6734fd | |||
| c0e0756b9a | |||
| 5b45d4354f | |||
| fb2773fee4 | |||
| ae7feeb726 | |||
| bcf5519880 | |||
| 70568f82c5 | |||
| 6fbd0bff28 | |||
| dff3eb14a1 | |||
| fab3d54f24 | |||
| 2c3ffc8c8a | |||
| 6f87563789 | |||
| 7005052156 | |||
| 29ae7089aa | |||
| 80e953a07b | |||
| aebf58068f | |||
| a491fe34bd | |||
| 830e64afe0 | |||
| 1068c1387a | |||
| d31632534b | |||
| 3272b4bb05 | |||
| dd177101f7 | |||
| 36571c4275 | |||
| 66f8577448 | |||
| 9ca3320a02 | |||
| 9ed4f086b0 | |||
| da855a7c89 | |||
| 7be404b918 | |||
| b71e58c8d2 | |||
| 1696e6f884 | |||
| 7093d368d3 | |||
| 905cea68b4 | |||
| dc5442223d | |||
| 3b03c253cc | |||
| db79cc9eb0 |
@@ -254,6 +254,18 @@ WEBHOOK_NOTIFY_DEVICES=true
|
||||
# - Подходит для продажи готовых пакетов услуг
|
||||
SALES_MODE=tariffs
|
||||
|
||||
# Управление сменой тарифа (для SALES_MODE=tariffs)
|
||||
# UPGRADE / DOWNGRADE:
|
||||
# true / true = все направления разрешены
|
||||
# true / false = только повышение (на более дорогой тариф)
|
||||
# false / true = только понижение (на более дешёвый тариф)
|
||||
# false / false = смена тарифа полностью отключена
|
||||
TARIFF_SWITCH_UPGRADE_ENABLED=true
|
||||
TARIFF_SWITCH_DOWNGRADE_ENABLED=true
|
||||
|
||||
# Сброс привязанных устройств при продлении подписки (однократно при каждом продлении)
|
||||
RESET_DEVICES_ON_RENEWAL=false
|
||||
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
@@ -974,6 +986,9 @@ DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
WEBHOOK_SECRET_TOKEN=
|
||||
# IP адрес сервера для setWebhook — Telegram будет использовать его напрямую без DNS резолва домена
|
||||
# Необходимо в регионах где Telegram не может резолвить домены (РФ и др.)
|
||||
# WEBHOOK_IP=
|
||||
WEBHOOK_DROP_PENDING_UPDATES=true
|
||||
WEBHOOK_MAX_QUEUE_SIZE=1024
|
||||
WEBHOOK_WORKERS=4
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.49.0"
|
||||
".": "3.52.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,89 @@
|
||||
# Changelog
|
||||
|
||||
## [3.52.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.51.0...v3.52.0) (2026-04-24)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* admin bulk actions API — mass operations on users ([fb2773f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fb2773fee4f36fc517624d96243b731c94a3508d))
|
||||
* bulk actions — campaign/partner filters, delete_user action ([d77fd81](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d77fd81e161a65ba848133ba4798a6a335e35c1c))
|
||||
* bulk actions — SSE streaming progress, grant subscription, multi-tariff info ([c0e0756](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c0e0756b9ac15cd926708be3f2f9c93e571ad411))
|
||||
* bulk delete_subscription action — removes from bot DB + RemnaWave ([605f202](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/605f202191d0203fdc7f5ea3bb0695ece47c9cd6))
|
||||
* bulk set_devices action + device info in subscription list ([be787a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be787a85bfd981b70bd347c943f5cef8ec0610fc))
|
||||
* FAQ support in info pages — page_type field + migration ([d394565](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d394565fe940549d1211a1ddab37f4d002d43dd5))
|
||||
* info page tab replacement — replaces_tab field + API ([bdb8cab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bdb8cab1c97328ff05218a9a68612418c69ccacf))
|
||||
* information pages — CRUD model, admin API, public API ([e4b4a54](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4b4a54797875b697146f8b804b3bbdfff4eb78b))
|
||||
* multi-tariff bulk actions — subscription-level targeting ([e78177b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e78177b2fc34c895ab2b74705516af2a3fcbfb70))
|
||||
* support multiple tariff_ids in user list filter ([0d2b1df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0d2b1dfdc97d2f3058ef793c89beaa8da5709a4a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* /reorder route unreachable — move before /{page_id} path param ([122d12d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/122d12db20537cf0ad55cea5328f03aa3464b540))
|
||||
* add subscription/tariff/promo_group filters to admin user list API ([daa4725](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/daa472570ccf6ef50441e0e173ddd1b87fe31e5e))
|
||||
* always return subscriptions list in user list API ([cfbcc30](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cfbcc3082fca3a6101604aea197e7844974ed15d))
|
||||
* bulk actions review — rollback on error, multi-tariff constraint checks ([5b45d43](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b45d4354f6e58bd609e69a45ef100acfed3dbf4))
|
||||
* bulk change_tariff not clearing squads when new tariff has none ([db7b673](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db7b6734fdcae52397a25eb94e1b24e908fcc41c))
|
||||
* bulk delete_user — pass real admin_id, sanitize error messages ([2e45a93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2e45a93bd72c7e930fad60f7be2292596271c0dc))
|
||||
* gift purchases no longer inflate promo group level ([1522d35](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1522d35f2dec0e35153c452818380f9b41b35f1d))
|
||||
* info pages review — deduplicate slug index, type reorder items ([2071a68](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2071a680d3d569f67c447921370463f13c928c55))
|
||||
* integrate Yandex Metrika offline conv + S2S postback hooks ([7d512d2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d512d214a1ca184ba6601bf1ee5a39727ed41e5))
|
||||
* MissingGreenlet in subscription-ids bulk actions ([2ad893b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2ad893badfec6f782ed89746b1cf2d3d7b07aa3a))
|
||||
* privacy policy and offer text display HTML links as plain text ([59c54c9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59c54c9b39d9cb26ed518cc74c93e1da43ce2020))
|
||||
* restore HTTP Referer fallback for landing purchases ([24dc8d2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/24dc8d2a5e1be5239036acf5888acfe1e8e96444))
|
||||
* restore referrer field in admin landing purchases response ([2cde38c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cde38c63b35e52a552bf58c003a8281525a0be7))
|
||||
* sanitize error messages in all bulk action catch-all handlers ([9217f41](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9217f4116f74d15133531a711cb2bc083f30dd49))
|
||||
* server squad sync fails on fresh DB without default promo group ([ae7feeb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ae7feeb726ab0a9b3074c9ed11fd45e2cc412ea0))
|
||||
* stop printing tracebacks for warning-level logs inside except blocks ([68d2350](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68d2350dfdf2f1f6964dd744a447b558a1d06267))
|
||||
* suppress 'User already enabled' traceback in bulk add_traffic ([ff41ea9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff41ea9abbdbeb6746e23cde48a5f962b289fc48))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add Overpay to README with partner block ([bcf5519](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bcf5519880471d113833a5031cae54594e658a7b))
|
||||
* add Overpay to README with partner block ([70568f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/70568f82c5c6c2c1041865c7109907fa9e2b8c6a))
|
||||
|
||||
## [3.51.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.50.0...v3.51.0) (2026-04-23)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* integrate Overpay payment provider (pay.overpay.io) ([2c3ffc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2c3ffc8c8a9e6591d2356320ed0a71ab5db6cbec))
|
||||
* respond to unknown media messages (photos, videos, documents) ([29ae708](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29ae7089aa0a8a5c7ddd73622a492df6b5e67e03))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* inactive user cleanup deletes users with paid subscriptions ([7005052](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/70050521566ab86b75fb4f64ed3b61bced5d1612))
|
||||
* pad short RemnaWave usernames to meet 3-char minimum ([6f87563](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6f875637899544d95daf8d87a30042b5347f6b41))
|
||||
|
||||
## [3.50.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.49.0...v3.50.0) (2026-04-22)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook ([#2894](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2894)) ([7093d36](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7093d368d3356c692bff3eddda68996579bfd036))
|
||||
* landing page analytics goals and sticky pay button ([3272b4b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3272b4bb053c76c3995cd3904b085a28f741e815))
|
||||
* tariff switch direction control, fix device pricing within tariff limit ([9ed4f08](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ed4f086b0102c20d7861e03f4d212ff57e28245))
|
||||
* **tickets:** multi-media message gallery (media_items JSONB) ([36571c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/36571c4275b76fa0bdb490ffccacc0a0e32e9bd6))
|
||||
* v3.50.0 release ([a491fe3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a491fe34bda6da86d72891fe4d05b6abd169cf2f))
|
||||
* Yandex Metrika offline conversions + S2S postbacks ([1068c13](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1068c1387a03bf7c94c7b29f9bb61acc3e3e782a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* classic mode renewal resets device_limit to 1 via cart key mismatch ([9ca3320](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ca3320a0204346aac2c76601f221b0bc70226a7))
|
||||
* do not reset subscription_crypto_link when cryptoLink absent in webhook ([#2891](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2891)) ([b71e58c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b71e58c8d25a5c94c94a401a59359cab4870e3c0))
|
||||
* do not update first_name/last_name from OIDC claims ([#2892](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2892)) ([1696e6f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1696e6f8843ceaa1a3e9a6d536e0d99968e579b1))
|
||||
* FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal ([7be404b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7be404b918640dcccdcda82802bb02a342b730d6))
|
||||
* grant all available squads for unrestricted trials ([#2897](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2897)) ([905cea6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/905cea68b48b596412d83c42de0d1dce77dcaee0))
|
||||
* menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports ([66f8577](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/66f8577448712786d851b1802c4a183e7acc1779))
|
||||
* tariff switch pricing showing free for upgrades, admin duplicate subscription guard ([da855a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da855a7c8955dcbf3543dbbaedc00e16b9c35193))
|
||||
* ticket media_items review fixes ([dd17710](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dd177101f7314c5d25120a69e04c66d74f37b18f))
|
||||
* validate analytics goal is set when analytics is enabled on landing ([d316325](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d31632534b3e1b2268a8f8b2a441214f377f23eb))
|
||||
* устранить MissingGreenlet в автоплатежах и починить traceback в логах ([db79cc9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db79cc9eb0d7dc7a4a1ae9190f7a23c6c9e6e317))
|
||||
* устранить root cause MissingGreenlet в автоплатежах через refetch по id ([3b03c25](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3b03c253cc1603fe54fddcbc8bc374ec2c71bdfc))
|
||||
|
||||
## [3.49.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.48.0...v3.49.0) (2026-04-18)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.49.0" # x-release-please-version
|
||||
ARG VERSION="v3.52.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ Bedolaga — полнофункциональная платформа для п
|
||||
| 🤝 | **[PayPear](https://t.me/Paymen1_Manager)** 🔸 | Карты, СБП, SberPay, T-Pay | RUB |
|
||||
| 🤝 | **[RollyPay](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)** 🔸 | СБП, карты, крипто | RUB → USDT |
|
||||
| 🤝 | **[AuraPay](https://aurapay.tech/)** 🔸 | Карты, СБП | RUB |
|
||||
| 🤝 | **[Overpay](https://overpay.pro/)** 🔸 | Карты, СБП | RUB |
|
||||
| 📲 | **Tribute** | Telegram-платежи | RUB |
|
||||
|
||||
</div>
|
||||
@@ -198,6 +199,17 @@ Bedolaga — официальный партнёр платёжной систе
|
||||
|
||||
📩 Менеджер: [@kickdownm](https://t.me/kickdownm) | 🌐 [aurapay.tech](https://aurapay.tech/)
|
||||
|
||||
</td>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр Overpay**
|
||||
|
||||
Bedolaga — официальный партнёр платёжного шлюза **[Overpay](https://overpay.pro/)**.<br>
|
||||
Банковские карты и СБП, mTLS-авторизация, HPP-интеграция.<br>
|
||||
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
|
||||
|
||||
📩 Менеджер: [@A_OverPay](https://t.me/A_OverPay) | 🌐 [overpay.pro](https://overpay.pro/)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -7,10 +7,12 @@ from .admin_apps import router as admin_apps_router
|
||||
from .admin_audit_log import router as admin_audit_log_router
|
||||
from .admin_ban_system import router as admin_ban_system_router
|
||||
from .admin_broadcasts import router as admin_broadcasts_router
|
||||
from .admin_bulk_actions import router as admin_bulk_actions_router
|
||||
from .admin_button_styles import router as admin_button_styles_router
|
||||
from .admin_campaigns import router as admin_campaigns_router
|
||||
from .admin_channels import router as admin_channels_router
|
||||
from .admin_email_templates import router as admin_email_templates_router
|
||||
from .admin_info_pages import router as admin_info_pages_router
|
||||
from .admin_landings import router as admin_landings_router
|
||||
from .admin_menu_layout import router as admin_menu_layout_router
|
||||
from .admin_news import router as admin_news_router
|
||||
@@ -44,6 +46,7 @@ from .branding import router as branding_router
|
||||
from .contests import router as contests_router
|
||||
from .gift import router as gift_router
|
||||
from .info import router as info_router
|
||||
from .info_pages import router as info_pages_router
|
||||
from .landing import router as landing_router
|
||||
from .media import router as media_router
|
||||
from .news import router as news_router
|
||||
@@ -93,6 +96,7 @@ router.include_router(branding_router)
|
||||
router.include_router(landing_router)
|
||||
router.include_router(media_router)
|
||||
router.include_router(news_router)
|
||||
router.include_router(info_pages_router)
|
||||
|
||||
# Wheel routes
|
||||
router.include_router(wheel_router)
|
||||
@@ -118,6 +122,7 @@ router.include_router(admin_campaigns_router)
|
||||
router.include_router(admin_partners_router)
|
||||
router.include_router(admin_withdrawals_router)
|
||||
router.include_router(admin_users_router)
|
||||
router.include_router(admin_bulk_actions_router)
|
||||
router.include_router(admin_payment_methods_router)
|
||||
router.include_router(admin_landings_router)
|
||||
router.include_router(admin_payments_router)
|
||||
@@ -140,6 +145,7 @@ router.include_router(admin_news_categories_router)
|
||||
router.include_router(admin_news_tags_router)
|
||||
router.include_router(admin_news_media_router)
|
||||
router.include_router(admin_news_router)
|
||||
router.include_router(admin_info_pages_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
"""Admin routes for managing info pages in cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.info_pages import (
|
||||
clear_replaces_tab,
|
||||
create_info_page,
|
||||
delete_info_page,
|
||||
get_all_info_pages,
|
||||
get_info_page_by_id,
|
||||
reorder_info_pages,
|
||||
update_info_page,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.info_pages import (
|
||||
InfoPageCreateRequest,
|
||||
InfoPageListItem,
|
||||
InfoPageResponse,
|
||||
InfoPageUpdateRequest,
|
||||
ReorderRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/info-pages', tags=['Cabinet Admin Info Pages'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[InfoPageListItem])
|
||||
async def list_all_info_pages(
|
||||
page_type: str | None = Query(None, pattern=r'^(page|faq)$'),
|
||||
admin: User = Depends(require_permission('settings:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[InfoPageListItem]:
|
||||
"""Get all info pages (admin view, includes inactive)."""
|
||||
try:
|
||||
pages = await get_all_info_pages(db, include_inactive=True, page_type=page_type)
|
||||
return [InfoPageListItem.model_validate(p) for p in pages]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Failed to list info pages')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load info pages',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{page_id}', response_model=InfoPageResponse)
|
||||
async def get_info_page_detail(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('settings:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Get a single info page by ID (admin view)."""
|
||||
page = await get_info_page_by_id(db, page_id)
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
return InfoPageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.post('', response_model=InfoPageResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_page(
|
||||
request: InfoPageCreateRequest,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Create a new info page."""
|
||||
try:
|
||||
if request.replaces_tab:
|
||||
await clear_replaces_tab(db, request.replaces_tab)
|
||||
|
||||
page = await create_info_page(
|
||||
db,
|
||||
slug=request.slug,
|
||||
title=request.title,
|
||||
content=request.content,
|
||||
page_type=request.page_type,
|
||||
is_active=request.is_active,
|
||||
sort_order=request.sort_order,
|
||||
icon=request.icon,
|
||||
replaces_tab=request.replaces_tab,
|
||||
)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An info page with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create info page')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create info page',
|
||||
)
|
||||
|
||||
return InfoPageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.put('/{page_id}', response_model=InfoPageResponse)
|
||||
async def update_page(
|
||||
page_id: int,
|
||||
request: InfoPageUpdateRequest,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Update an existing info page."""
|
||||
existing = await get_info_page_by_id(db, page_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
try:
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
|
||||
replaces_tab = update_data.get('replaces_tab')
|
||||
if replaces_tab is not None:
|
||||
await clear_replaces_tab(db, replaces_tab, exclude_page_id=page_id)
|
||||
|
||||
page = await update_info_page(db, page_id, **update_data)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An info page with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to update info page', page_id=page_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update info page',
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found after update',
|
||||
)
|
||||
return InfoPageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.delete('/{page_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_page(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete an info page."""
|
||||
existing = await get_info_page_by_id(db, page_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
try:
|
||||
await delete_info_page(db, page_id)
|
||||
except Exception:
|
||||
logger.exception('Failed to delete info page', page_id=page_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to delete info page',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/reorder', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def reorder_pages(
|
||||
request: ReorderRequest,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Bulk update sort_order for info pages."""
|
||||
try:
|
||||
await reorder_info_pages(db, request.items)
|
||||
except Exception:
|
||||
logger.exception('Failed to reorder info pages')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reorder info pages',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{page_id}/toggle-active', response_model=InfoPageResponse)
|
||||
async def toggle_active(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Toggle the active status of an info page."""
|
||||
existing = await get_info_page_by_id(db, page_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
try:
|
||||
page = await update_info_page(db, page_id, is_active=not existing.is_active)
|
||||
except Exception:
|
||||
logger.exception('Failed to toggle info page active status', page_id=page_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to toggle active status',
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found after toggle',
|
||||
)
|
||||
return InfoPageResponse.model_validate(page)
|
||||
@@ -205,6 +205,19 @@ class LandingCreateRequest(BaseModel):
|
||||
discount_ends_at: datetime | None = None
|
||||
discount_badge_text: dict[str, str] | None = None
|
||||
background_config: dict | None = None
|
||||
sticky_pay_button: bool = False
|
||||
analytics_view_enabled: bool = False
|
||||
analytics_view_goal: str | None = Field(default=None, max_length=64)
|
||||
analytics_click_enabled: bool = False
|
||||
analytics_click_goal: str | None = Field(default=None, max_length=64)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_analytics_goals(self) -> 'LandingCreateRequest':
|
||||
if self.analytics_view_enabled and not self.analytics_view_goal:
|
||||
raise ValueError('analytics_view_goal is required when analytics_view_enabled is True')
|
||||
if self.analytics_click_enabled and not self.analytics_click_goal:
|
||||
raise ValueError('analytics_click_goal is required when analytics_click_enabled is True')
|
||||
return self
|
||||
|
||||
@field_validator('background_config')
|
||||
@classmethod
|
||||
@@ -314,6 +327,11 @@ class LandingUpdateRequest(BaseModel):
|
||||
discount_ends_at: datetime | None = None
|
||||
discount_badge_text: dict[str, str] | None = None
|
||||
background_config: dict | None = None
|
||||
sticky_pay_button: bool | None = None
|
||||
analytics_view_enabled: bool | None = None
|
||||
analytics_view_goal: str | None = Field(default=None, max_length=64)
|
||||
analytics_click_enabled: bool | None = None
|
||||
analytics_click_goal: str | None = Field(default=None, max_length=64)
|
||||
|
||||
@field_validator('background_config')
|
||||
@classmethod
|
||||
@@ -461,6 +479,11 @@ class LandingDetailResponse(BaseModel):
|
||||
discount_ends_at: datetime | None = None
|
||||
discount_badge_text: dict[str, str] | None = None
|
||||
background_config: dict | None = None
|
||||
sticky_pay_button: bool = False
|
||||
analytics_view_enabled: bool = False
|
||||
analytics_view_goal: str | None = None
|
||||
analytics_click_enabled: bool = False
|
||||
analytics_click_goal: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -527,6 +550,7 @@ class LandingPurchaseItem(BaseModel):
|
||||
currency: str
|
||||
payment_method: str | None = None
|
||||
status: str
|
||||
referrer: str | None = None
|
||||
created_at: datetime | None = None
|
||||
paid_at: datetime | None = None
|
||||
|
||||
@@ -638,6 +662,11 @@ async def create_landing_page(
|
||||
discount_ends_at=request.discount_ends_at,
|
||||
discount_badge_text=request.discount_badge_text,
|
||||
background_config=request.background_config,
|
||||
sticky_pay_button=request.sticky_pay_button,
|
||||
analytics_view_enabled=request.analytics_view_enabled,
|
||||
analytics_view_goal=request.analytics_view_goal,
|
||||
analytics_click_enabled=request.analytics_click_enabled,
|
||||
analytics_click_goal=request.analytics_click_goal,
|
||||
)
|
||||
|
||||
logger.info('Admin created landing page', admin_id=admin.id, slug=landing.slug, landing_id=landing.id)
|
||||
@@ -979,6 +1008,7 @@ async def get_landing_purchases(
|
||||
GuestPurchase.currency,
|
||||
GuestPurchase.payment_method,
|
||||
GuestPurchase.status,
|
||||
GuestPurchase.referrer,
|
||||
GuestPurchase.created_at,
|
||||
GuestPurchase.paid_at,
|
||||
)
|
||||
@@ -1004,6 +1034,7 @@ async def get_landing_purchases(
|
||||
currency=row.currency,
|
||||
payment_method=row.payment_method,
|
||||
status=row.status,
|
||||
referrer=row.referrer,
|
||||
created_at=row.created_at,
|
||||
paid_at=row.paid_at,
|
||||
)
|
||||
@@ -1068,6 +1099,11 @@ def _landing_to_detail(landing: LandingPage) -> LandingDetailResponse:
|
||||
discount_ends_at=landing.discount_ends_at,
|
||||
discount_badge_text=landing.discount_badge_text,
|
||||
background_config=landing.background_config,
|
||||
sticky_pay_button=landing.sticky_pay_button,
|
||||
analytics_view_enabled=landing.analytics_view_enabled,
|
||||
analytics_view_goal=landing.analytics_view_goal,
|
||||
analytics_click_enabled=landing.analytics_click_enabled,
|
||||
analytics_click_goal=landing.analytics_click_goal,
|
||||
created_at=landing.created_at,
|
||||
updated_at=landing.updated_at,
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.database.crud.ticket_notification import TicketNotificationCRUD
|
||||
from app.database.models import Ticket, TicketMessage, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.tickets import TicketMessageResponse
|
||||
from ..schemas.tickets import TicketMediaItem, TicketMessageResponse, _validate_media_bundle
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -89,19 +89,19 @@ class AdminTicketListResponse(BaseModel):
|
||||
class AdminReplyRequest(BaseModel):
|
||||
"""Admin reply to ticket."""
|
||||
|
||||
message: str = Field(..., min_length=1, max_length=4000, description='Reply message')
|
||||
message: str = Field(default='', max_length=4000, description='Reply message')
|
||||
media_type: str | None = Field(None, description='Media type: photo, video, or document')
|
||||
media_file_id: str | None = Field(None, max_length=255, description='Telegram file_id from media upload')
|
||||
media_caption: str | None = Field(None, max_length=1000, description='Caption for media')
|
||||
media_items: list[TicketMediaItem] | None = Field(None, description='Multi-media gallery attachments')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_media_fields(self) -> 'AdminReplyRequest':
|
||||
if self.media_file_id and not self.media_type:
|
||||
raise ValueError('media_type is required when media_file_id is provided')
|
||||
if self.media_type and not self.media_file_id:
|
||||
raise ValueError('media_file_id is required when media_type is provided')
|
||||
if self.media_type and self.media_type not in {'photo', 'video', 'document'}:
|
||||
raise ValueError('media_type must be one of: photo, video, document')
|
||||
_validate_media_bundle(self.media_type, self.media_file_id, self.media_items)
|
||||
has_text = bool(self.message.strip())
|
||||
has_media = bool(self.media_file_id) or bool(self.media_items)
|
||||
if not has_text and not has_media:
|
||||
raise ValueError('message or media is required')
|
||||
return self
|
||||
|
||||
|
||||
@@ -157,14 +157,23 @@ class TicketSettingsUpdateRequest(BaseModel):
|
||||
|
||||
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
|
||||
"""Convert TicketMessage to response."""
|
||||
raw_items = getattr(message, 'media_items', None) or None
|
||||
items = None
|
||||
if raw_items:
|
||||
try:
|
||||
items = [TicketMediaItem(**it) for it in raw_items]
|
||||
except (TypeError, KeyError, ValueError) as exc:
|
||||
logger.warning('Failed to parse media_items', message_id=message.id, error=str(exc))
|
||||
items = None
|
||||
return TicketMessageResponse(
|
||||
id=message.id,
|
||||
message_text=message.message_text or '',
|
||||
is_from_admin=message.is_from_admin,
|
||||
has_media=bool(message.media_file_id),
|
||||
has_media=bool(message.media_file_id) or bool(items),
|
||||
media_type=message.media_type,
|
||||
media_file_id=message.media_file_id,
|
||||
media_caption=message.media_caption,
|
||||
media_items=items,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
@@ -455,17 +464,29 @@ async def reply_to_ticket(
|
||||
detail='Ticket not found',
|
||||
)
|
||||
|
||||
# Create admin message
|
||||
has_media = bool(request.media_file_id)
|
||||
# Resolve media payload: prefer media_items, fall back to legacy single-media fields
|
||||
items_payload = None
|
||||
primary_type = request.media_type
|
||||
primary_file_id = request.media_file_id
|
||||
primary_caption = request.media_caption
|
||||
if request.media_items:
|
||||
items_payload = [it.model_dump() for it in request.media_items]
|
||||
first = request.media_items[0]
|
||||
primary_type = first.type
|
||||
primary_file_id = first.file_id
|
||||
primary_caption = primary_caption or first.caption
|
||||
has_media = bool(primary_file_id)
|
||||
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=ticket.user_id,
|
||||
message_text=request.message,
|
||||
is_from_admin=True,
|
||||
has_media=has_media,
|
||||
media_type=request.media_type if has_media else None,
|
||||
media_file_id=request.media_file_id if has_media else None,
|
||||
media_caption=request.media_caption if has_media else None,
|
||||
media_type=primary_type if has_media else None,
|
||||
media_file_id=primary_file_id if has_media else None,
|
||||
media_caption=primary_caption if has_media else None,
|
||||
media_items=items_payload,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
@@ -70,6 +70,7 @@ from ..schemas.users import (
|
||||
ResetTrialRequest,
|
||||
ResetTrialResponse,
|
||||
SortByEnum,
|
||||
SubscriptionListItem,
|
||||
SyncFromPanelRequest,
|
||||
SyncFromPanelResponse,
|
||||
SyncToPanelRequest,
|
||||
@@ -119,6 +120,12 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt
|
||||
subscription_is_trial = False
|
||||
subscription_end_date = None
|
||||
has_subscription = False
|
||||
tariff_id = None
|
||||
tariff_name = None
|
||||
traffic_used_gb = 0.0
|
||||
traffic_limit_gb = 0
|
||||
device_limit = 0
|
||||
days_remaining = 0
|
||||
|
||||
subs = getattr(user, 'subscriptions', None) or []
|
||||
subscription = next((s for s in subs if s.is_active), subs[0] if subs else None)
|
||||
@@ -127,6 +134,36 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt
|
||||
subscription_status = subscription.status
|
||||
subscription_is_trial = subscription.is_trial
|
||||
subscription_end_date = subscription.end_date
|
||||
tariff_id = subscription.tariff_id
|
||||
tariff_name = subscription.tariff.name if subscription.tariff else None
|
||||
traffic_used_gb = subscription.traffic_used_gb or 0.0
|
||||
traffic_limit_gb = subscription.traffic_limit_gb or 0
|
||||
device_limit = subscription.device_limit or 0
|
||||
if subscription.end_date:
|
||||
delta = subscription.end_date - datetime.now(UTC)
|
||||
days_remaining = max(0, delta.days)
|
||||
|
||||
# Build per-subscription list (always — bulk actions need it for any mode)
|
||||
sub_list: list[SubscriptionListItem] = []
|
||||
if subs:
|
||||
for s in subs:
|
||||
s_days = 0
|
||||
if s.end_date:
|
||||
s_delta = s.end_date - datetime.now(UTC)
|
||||
s_days = max(0, s_delta.days)
|
||||
sub_list.append(
|
||||
SubscriptionListItem(
|
||||
id=s.id,
|
||||
tariff_id=s.tariff_id,
|
||||
tariff_name=s.tariff.name if s.tariff else None,
|
||||
status=s.status,
|
||||
end_date=s.end_date,
|
||||
days_remaining=s_days,
|
||||
traffic_used_gb=s.traffic_used_gb or 0.0,
|
||||
traffic_limit_gb=s.traffic_limit_gb or 0,
|
||||
device_limit=s.device_limit or 0,
|
||||
)
|
||||
)
|
||||
|
||||
return UserListItem(
|
||||
id=user.id,
|
||||
@@ -144,6 +181,13 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt
|
||||
subscription_status=subscription_status,
|
||||
subscription_is_trial=subscription_is_trial,
|
||||
subscription_end_date=subscription_end_date,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff_name,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
days_remaining=days_remaining,
|
||||
subscriptions=sub_list,
|
||||
promo_group_id=user.promo_group_id,
|
||||
promo_group_name=user.promo_group.name if user.promo_group else None,
|
||||
total_spent_kopeks=user_stats.get('total_spent', 0),
|
||||
@@ -417,6 +461,11 @@ async def list_users(
|
||||
search: str | None = Query(None, max_length=255),
|
||||
email: str | None = Query(None, max_length=255),
|
||||
status: UserStatusEnum | None = Query(None),
|
||||
subscription_status: str | None = Query(None, max_length=20),
|
||||
tariff_id: str | None = Query(None, max_length=255),
|
||||
promo_group_id: int | None = Query(None),
|
||||
campaign_id: int | None = Query(None),
|
||||
partner_id: int | None = Query(None),
|
||||
sort_by: SortByEnum = Query(SortByEnum.CREATED_AT),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -443,6 +492,14 @@ async def list_users(
|
||||
order_by_total_spent = sort_by == SortByEnum.TOTAL_SPENT
|
||||
order_by_purchase_count = sort_by == SortByEnum.PURCHASE_COUNT
|
||||
|
||||
# Parse comma-separated tariff_ids
|
||||
tariff_ids: list[int] | None = None
|
||||
if tariff_id:
|
||||
try:
|
||||
tariff_ids = [int(x.strip()) for x in tariff_id.split(',') if x.strip()]
|
||||
except ValueError:
|
||||
tariff_ids = None
|
||||
|
||||
users = await get_users_list(
|
||||
db=db,
|
||||
offset=offset,
|
||||
@@ -450,6 +507,11 @@ async def list_users(
|
||||
search=search,
|
||||
email=email,
|
||||
status=user_status,
|
||||
subscription_status=subscription_status,
|
||||
tariff_ids=tariff_ids,
|
||||
promo_group_id=promo_group_id,
|
||||
campaign_id=campaign_id,
|
||||
partner_id=partner_id,
|
||||
order_by_balance=order_by_balance,
|
||||
order_by_traffic=order_by_traffic,
|
||||
order_by_last_activity=order_by_last_activity,
|
||||
@@ -457,7 +519,17 @@ async def list_users(
|
||||
order_by_purchase_count=order_by_purchase_count,
|
||||
)
|
||||
|
||||
total = await get_users_count(db=db, status=user_status, search=search, email=email)
|
||||
total = await get_users_count(
|
||||
db=db,
|
||||
status=user_status,
|
||||
search=search,
|
||||
email=email,
|
||||
subscription_status=subscription_status,
|
||||
tariff_ids=tariff_ids,
|
||||
promo_group_id=promo_group_id,
|
||||
campaign_id=campaign_id,
|
||||
partner_id=partner_id,
|
||||
)
|
||||
|
||||
# Get spending stats for all users
|
||||
user_ids = [u.id for u in users]
|
||||
@@ -1050,6 +1122,17 @@ async def update_user_subscription(
|
||||
detail='User already has a subscription. Enable multi-tariff mode to add more.',
|
||||
)
|
||||
|
||||
# Проверка: нельзя создать вторую активную подписку с тем же тарифом
|
||||
if is_multi_tariff and request.tariff_id:
|
||||
from app.database.crud.subscription import get_subscription_by_user_and_tariff
|
||||
|
||||
existing = await get_subscription_by_user_and_tariff(db, user.id, request.tariff_id)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='User already has an active subscription for this tariff. Extend it instead.',
|
||||
)
|
||||
|
||||
from app.database.crud.subscription import create_paid_subscription
|
||||
|
||||
days = request.days or 30
|
||||
@@ -1069,16 +1152,25 @@ async def update_user_subscription(
|
||||
if tariff.allowed_squads:
|
||||
connected_squads = tariff.allowed_squads
|
||||
|
||||
new_sub = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=days,
|
||||
traffic_limit_gb=traffic_limit,
|
||||
device_limit=device_limit,
|
||||
is_trial=is_trial,
|
||||
tariff_id=request.tariff_id,
|
||||
connected_squads=connected_squads,
|
||||
)
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
try:
|
||||
new_sub = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=days,
|
||||
traffic_limit_gb=traffic_limit,
|
||||
device_limit=device_limit,
|
||||
is_trial=is_trial,
|
||||
tariff_id=request.tariff_id,
|
||||
connected_squads=connected_squads,
|
||||
)
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='User already has an active subscription for this tariff. Extend it instead.',
|
||||
)
|
||||
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, new_sub)
|
||||
@@ -1191,6 +1283,18 @@ async def update_user_subscription(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Проверка: нельзя сменить тариф, если у пользователя уже есть
|
||||
# другая активная подписка с целевым тарифом
|
||||
if is_multi_tariff and request.tariff_id != subscription.tariff_id:
|
||||
from app.database.crud.subscription import get_subscription_by_user_and_tariff
|
||||
|
||||
existing = await get_subscription_by_user_and_tariff(db, user.id, request.tariff_id)
|
||||
if existing and existing.id != subscription.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='User already has an active subscription for the target tariff',
|
||||
)
|
||||
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
@@ -1322,6 +1426,18 @@ async def update_user_subscription(
|
||||
)
|
||||
|
||||
if request.action == 'activate':
|
||||
# Проверка: нельзя активировать, если у пользователя уже есть
|
||||
# другая активная подписка с тем же тарифом
|
||||
if is_multi_tariff and subscription.tariff_id:
|
||||
from app.database.crud.subscription import get_subscription_by_user_and_tariff
|
||||
|
||||
existing = await get_subscription_by_user_and_tariff(db, user.id, subscription.tariff_id)
|
||||
if existing and existing.id != subscription.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Cannot activate: user already has an active subscription for this tariff',
|
||||
)
|
||||
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
|
||||
# Extend by 30 days if expired
|
||||
|
||||
@@ -815,10 +815,9 @@ async def auth_telegram_oidc(
|
||||
# Update user info from OIDC claims
|
||||
if username and username != user.username:
|
||||
user.username = username
|
||||
if first_name and first_name != user.first_name:
|
||||
user.first_name = first_name
|
||||
if last_name is not None and last_name != user.last_name:
|
||||
user.last_name = last_name
|
||||
# NOTE: не обновляем first_name/last_name из OIDC
|
||||
# Telegram OIDC возвращает только поле name как полное имя без разделения на first/last
|
||||
# Имя правильно заполняется через middleware при обычном использовании бота
|
||||
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
@@ -854,6 +854,35 @@ async def create_topup(
|
||||
detail='Failed to create RollyPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'overpay':
|
||||
if not settings.is_overpay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Overpay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_overpay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Overpay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'aurapay':
|
||||
if not settings.is_aurapay_enabled():
|
||||
raise HTTPException(
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.config import settings
|
||||
from app.database.crud.system_setting import get_setting_value
|
||||
from app.database.models import SystemSetting, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -291,12 +291,24 @@ class GiftEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class OfflineConvGoal(BaseModel):
|
||||
"""Yandex Metrika offline conversion goal descriptor."""
|
||||
|
||||
name: str
|
||||
event_id: str
|
||||
dedup: str
|
||||
|
||||
|
||||
class AnalyticsCountersResponse(BaseModel):
|
||||
"""Analytics counter settings."""
|
||||
|
||||
yandex_metrika_id: str = ''
|
||||
google_ads_id: str = ''
|
||||
google_ads_label: str = ''
|
||||
offline_conv_enabled: bool = False
|
||||
offline_conv_counter_id: str = ''
|
||||
offline_conv_measurement_secret_masked: str = ''
|
||||
offline_conv_goals: list[OfflineConvGoal] = []
|
||||
|
||||
|
||||
class AnalyticsCountersUpdate(BaseModel):
|
||||
@@ -924,10 +936,27 @@ async def get_analytics_counters(
|
||||
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
|
||||
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
|
||||
|
||||
# Yandex Metrika offline conversions snapshot from Settings
|
||||
oc_enabled = bool(getattr(settings, 'YANDEX_OFFLINE_CONV_ENABLED', False))
|
||||
oc_counter = str(getattr(settings, 'YANDEX_OFFLINE_CONV_COUNTER_ID', '') or '')
|
||||
oc_secret = str(getattr(settings, 'YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET', '') or '')
|
||||
oc_secret_masked = ('*' * 8 + oc_secret[-4:]) if len(oc_secret) > 4 else ('***' if oc_secret else '')
|
||||
oc_goals: list[OfflineConvGoal] = []
|
||||
if oc_enabled:
|
||||
oc_goals = [
|
||||
OfflineConvGoal(name='Registration', event_id='registration', dedup='user_id'),
|
||||
OfflineConvGoal(name='Trial', event_id='trial-add', dedup='user_id'),
|
||||
OfflineConvGoal(name='Purchase', event_id='purchase', dedup='order_id'),
|
||||
]
|
||||
|
||||
return AnalyticsCountersResponse(
|
||||
yandex_metrika_id=yandex_id,
|
||||
google_ads_id=google_id,
|
||||
google_ads_label=google_label,
|
||||
offline_conv_enabled=oc_enabled,
|
||||
offline_conv_counter_id=oc_counter,
|
||||
offline_conv_measurement_secret_masked=oc_secret_masked,
|
||||
offline_conv_goals=oc_goals,
|
||||
)
|
||||
|
||||
|
||||
@@ -966,13 +995,56 @@ async def update_analytics_counters(
|
||||
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
|
||||
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
|
||||
|
||||
oc_enabled = bool(getattr(settings, 'YANDEX_OFFLINE_CONV_ENABLED', False))
|
||||
oc_counter = str(getattr(settings, 'YANDEX_OFFLINE_CONV_COUNTER_ID', '') or '')
|
||||
oc_secret = str(getattr(settings, 'YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET', '') or '')
|
||||
oc_secret_masked = ('*' * 8 + oc_secret[-4:]) if len(oc_secret) > 4 else ('***' if oc_secret else '')
|
||||
oc_goals: list[OfflineConvGoal] = []
|
||||
if oc_enabled:
|
||||
oc_goals = [
|
||||
OfflineConvGoal(name='Registration', event_id='registration', dedup='user_id'),
|
||||
OfflineConvGoal(name='Trial', event_id='trial-add', dedup='user_id'),
|
||||
OfflineConvGoal(name='Purchase', event_id='purchase', dedup='order_id'),
|
||||
]
|
||||
|
||||
return AnalyticsCountersResponse(
|
||||
yandex_metrika_id=yandex_id,
|
||||
google_ads_id=google_id,
|
||||
google_ads_label=google_label,
|
||||
offline_conv_enabled=oc_enabled,
|
||||
offline_conv_counter_id=oc_counter,
|
||||
offline_conv_measurement_secret_masked=oc_secret_masked,
|
||||
offline_conv_goals=oc_goals,
|
||||
)
|
||||
|
||||
|
||||
# ============ Yandex CID Sync ============
|
||||
|
||||
|
||||
class YandexCidRequest(BaseModel):
|
||||
cid: str = Field(max_length=128, pattern=r'^[A-Za-z0-9._:-]{4,128}$')
|
||||
|
||||
|
||||
@router.post('/analytics/yandex-cid', status_code=204)
|
||||
async def store_yandex_cid(
|
||||
body: YandexCidRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Store Yandex Metrika ClientID for the authenticated cabinet user."""
|
||||
try:
|
||||
from app.services import yandex_offline_conv_service as yandex_conv
|
||||
|
||||
await yandex_conv.store_cid(db, user.id, body.cid, source='cabinet')
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to store yandex_cid', user_id=user.id, exc=str(exc))
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ============ Lite Mode Routes ============
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Public info page routes for cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.info_pages import get_all_info_pages, get_info_page_by_slug, get_tab_replacements
|
||||
|
||||
from ..dependencies import get_cabinet_db
|
||||
from ..schemas.info_pages import InfoPageListItem, InfoPageResponse
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/info-pages', tags=['Cabinet Info Pages'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[InfoPageListItem])
|
||||
async def list_active_info_pages(
|
||||
page_type: str | None = Query(None, pattern=r'^(page|faq)$'),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[InfoPageListItem]:
|
||||
"""Get all active info pages (public, no auth required)."""
|
||||
try:
|
||||
pages = await get_all_info_pages(db, include_inactive=False, page_type=page_type)
|
||||
return [InfoPageListItem.model_validate(p) for p in pages]
|
||||
except Exception:
|
||||
logger.exception('Failed to list active info pages')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load info pages',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/tab-replacements')
|
||||
async def get_info_page_tab_replacements(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, str | None]:
|
||||
"""Get tab replacement mapping (public, no auth required).
|
||||
|
||||
Returns a dict mapping each replaceable tab to the info page slug that replaces it,
|
||||
or null if no replacement is set: ``{faq: slug_or_null, ...}``.
|
||||
"""
|
||||
try:
|
||||
return await get_tab_replacements(db)
|
||||
except Exception:
|
||||
logger.exception('Failed to get tab replacements')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load tab replacements',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{slug}', response_model=InfoPageResponse)
|
||||
async def get_info_page_by_slug_public(
|
||||
slug: str = Path(..., max_length=200, pattern=r'^[a-z0-9\-]+$'),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Get a single info page by slug (public, no auth required)."""
|
||||
page = await get_info_page_by_slug(db, slug)
|
||||
|
||||
if not page or not page.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
return InfoPageResponse.model_validate(page)
|
||||
@@ -23,7 +23,7 @@ from app.services.guest_purchase_service import (
|
||||
)
|
||||
from app.services.payment_method_config_service import _get_method_defaults
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.cache import RateLimitCache
|
||||
from app.utils.cache import RateLimitCache, cache
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -99,6 +99,11 @@ class LandingConfigResponse(BaseModel):
|
||||
meta_description: str | None = None
|
||||
discount: LandingDiscountInfo | None = None # null if no active discount
|
||||
background_config: dict | None = None
|
||||
sticky_pay_button: bool = False
|
||||
analytics_view_enabled: bool = False
|
||||
analytics_view_goal: str | None = None
|
||||
analytics_click_enabled: bool = False
|
||||
analytics_click_goal: str | None = None
|
||||
|
||||
|
||||
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
|
||||
@@ -123,6 +128,9 @@ class PurchaseRequest(BaseModel):
|
||||
gift_recipient_type: str | None = Field(default=None, pattern=r'^(email|telegram)$')
|
||||
gift_recipient_value: str | None = Field(default=None, max_length=255)
|
||||
gift_message: str | None = Field(default=None, max_length=1000)
|
||||
yandex_cid: str | None = Field(default=None, max_length=128, pattern=r'^[A-Za-z0-9._:-]{4,128}$')
|
||||
referrer: str | None = Field(default=None, max_length=500)
|
||||
subid: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_contacts(self) -> 'PurchaseRequest':
|
||||
@@ -535,6 +543,11 @@ async def get_landing_config(
|
||||
meta_description=resolve_locale_text(landing.meta_description, lang) or None,
|
||||
discount=discount,
|
||||
background_config=landing.background_config,
|
||||
sticky_pay_button=landing.sticky_pay_button,
|
||||
analytics_view_enabled=landing.analytics_view_enabled,
|
||||
analytics_view_goal=landing.analytics_view_goal,
|
||||
analytics_click_enabled=landing.analytics_click_enabled,
|
||||
analytics_click_goal=landing.analytics_click_goal,
|
||||
)
|
||||
|
||||
|
||||
@@ -644,9 +657,17 @@ async def create_landing_purchase(
|
||||
gift_recipient_type=body.gift_recipient_type,
|
||||
gift_recipient_value=body.gift_recipient_value,
|
||||
gift_message=body.gift_message,
|
||||
subid=body.subid,
|
||||
referrer=body.referrer,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Fallback to HTTP Referer header if body did not supply one
|
||||
if not purchase.referrer:
|
||||
http_referrer = raw_request.headers.get('referer') or raw_request.headers.get('referrer')
|
||||
if http_referrer and len(http_referrer) <= 500:
|
||||
purchase.referrer = http_referrer
|
||||
|
||||
# Determine return URL: per-method override → default cabinet URL
|
||||
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
|
||||
default_return_url = f'{cabinet_base}/buy/success/{purchase.token}'
|
||||
@@ -690,6 +711,20 @@ async def create_landing_purchase(
|
||||
await db.commit()
|
||||
await db.refresh(purchase)
|
||||
|
||||
# Persist Yandex CID in cache so fulfill_purchase can link it to the user later
|
||||
if body.yandex_cid and settings.YANDEX_OFFLINE_CONV_ENABLED:
|
||||
try:
|
||||
await cache.set(f'yacid:purchase:{purchase.token}', body.yandex_cid, expire=86400)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Persist subid in cache for S2S postback
|
||||
if body.subid:
|
||||
try:
|
||||
await cache.set(f'subid:purchase:{purchase.token}', body.subid, expire=86400)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return PurchaseResponse(
|
||||
purchase_token=purchase.token,
|
||||
payment_url=payment_url,
|
||||
|
||||
@@ -108,7 +108,24 @@ async def purchase_devices_legacy(
|
||||
detail='Докупка устройств недоступна',
|
||||
)
|
||||
|
||||
base_total_price = device_price * request.devices
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
current_devices = subscription.device_limit or 1
|
||||
if tariff:
|
||||
tariff_included = tariff.device_limit or 0
|
||||
if current_devices < tariff_included:
|
||||
free_devices = tariff_included - current_devices
|
||||
chargeable_devices = max(0, request.devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = request.devices
|
||||
else:
|
||||
free_baseline = settings.DEFAULT_DEVICE_LIMIT
|
||||
if current_devices < free_baseline:
|
||||
free_devices = free_baseline - current_devices
|
||||
chargeable_devices = max(0, request.devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = request.devices
|
||||
|
||||
base_total_price = device_price * chargeable_devices
|
||||
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
@@ -366,10 +383,27 @@ async def purchase_devices(
|
||||
days_left = max(1, (end_date - now).days)
|
||||
total_days = 30 # Base period for device price calculation
|
||||
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
if tariff:
|
||||
tariff_included = tariff.device_limit or 0
|
||||
if current_devices < tariff_included:
|
||||
free_devices = tariff_included - current_devices
|
||||
chargeable_devices = max(0, request.devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = request.devices
|
||||
else:
|
||||
free_baseline = settings.DEFAULT_DEVICE_LIMIT
|
||||
if current_devices < free_baseline:
|
||||
free_devices = free_baseline - current_devices
|
||||
chargeable_devices = max(0, request.devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = request.devices
|
||||
|
||||
# Calculate base price before discount
|
||||
base_price_per_month = device_price * request.devices
|
||||
base_price_per_month = device_price * chargeable_devices
|
||||
base_price_prorated = int(base_price_per_month * days_left / total_days)
|
||||
base_price_prorated = max(100, base_price_prorated) # Minimum 1 ruble
|
||||
if chargeable_devices > 0:
|
||||
base_price_prorated = max(100, base_price_prorated) # Minimum 1 ruble
|
||||
|
||||
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
@@ -627,8 +661,25 @@ async def save_devices_cart(
|
||||
days_left = max(1, (end_date - now).days)
|
||||
total_days = 30
|
||||
|
||||
base_total_price = int(device_price * request.devices * days_left / total_days)
|
||||
base_total_price = max(100, base_total_price) # Minimum 1 ruble
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
if tariff:
|
||||
tariff_included = tariff.device_limit or 0
|
||||
if current_devices < tariff_included:
|
||||
free_devices = tariff_included - current_devices
|
||||
chargeable_devices = max(0, request.devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = request.devices
|
||||
else:
|
||||
free_baseline = settings.DEFAULT_DEVICE_LIMIT
|
||||
if current_devices < free_baseline:
|
||||
free_devices = free_baseline - current_devices
|
||||
chargeable_devices = max(0, request.devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = request.devices
|
||||
|
||||
base_total_price = int(device_price * chargeable_devices * days_left / total_days)
|
||||
if chargeable_devices > 0:
|
||||
base_total_price = max(100, base_total_price) # Minimum 1 ruble
|
||||
|
||||
# Apply discount from promo group
|
||||
period_hint_days = days_left
|
||||
@@ -724,9 +775,26 @@ async def get_device_price(
|
||||
days_left = max(1, (end_date - now).days)
|
||||
total_days = 30
|
||||
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
if tariff:
|
||||
tariff_included = tariff.device_limit or 0
|
||||
if current_devices < tariff_included:
|
||||
free_devices = tariff_included - current_devices
|
||||
chargeable_devices = max(0, devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = devices
|
||||
else:
|
||||
free_baseline = settings.DEFAULT_DEVICE_LIMIT
|
||||
if current_devices < free_baseline:
|
||||
free_devices = free_baseline - current_devices
|
||||
chargeable_devices = max(0, devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = devices
|
||||
|
||||
# Calculate base price before discount (total first, then floor)
|
||||
base_total_price = int(device_price * devices * days_left / total_days)
|
||||
base_total_price = max(100, base_total_price)
|
||||
base_total_price = int(device_price * chargeable_devices * days_left / total_days)
|
||||
if chargeable_devices > 0:
|
||||
base_total_price = max(100, base_total_price)
|
||||
|
||||
# Apply discount from promo group
|
||||
period_hint_days = days_left
|
||||
|
||||
@@ -353,6 +353,9 @@ async def get_purchase_options(
|
||||
'all_tariffs_purchased': len(purchased_tariff_ids) >= len(tariffs)
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else False,
|
||||
# Направления смены тарифа
|
||||
'tariff_switch_upgrade_enabled': settings.TARIFF_SWITCH_UPGRADE_ENABLED,
|
||||
'tariff_switch_downgrade_enabled': settings.TARIFF_SWITCH_DOWNGRADE_ENABLED,
|
||||
}
|
||||
|
||||
# Classic mode - return periods
|
||||
@@ -1219,9 +1222,11 @@ async def activate_trial(
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
trial_device_limit = trial_tariff.device_limit
|
||||
trial_squads = trial_tariff.allowed_squads or []
|
||||
trial_squads = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
|
||||
tariff_id_for_trial = trial_tariff.id
|
||||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
@@ -1235,7 +1240,7 @@ async def activate_trial(
|
||||
except Exception as e:
|
||||
logger.error('Error getting trial tariff', error=e)
|
||||
|
||||
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
|
||||
# No trial tariff configured, use the legacy random trial squad fallback.
|
||||
if not trial_squads:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
|
||||
@@ -57,7 +57,14 @@ async def get_renewal_options(
|
||||
return []
|
||||
|
||||
# Determine available periods
|
||||
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
|
||||
# Скрытый/неактивный тариф (например, триальный после промокода) —
|
||||
# не показываем его периоды, используем стандартные
|
||||
if (
|
||||
subscription.tariff_id
|
||||
and subscription.tariff
|
||||
and subscription.tariff.is_active
|
||||
and subscription.tariff.period_prices
|
||||
):
|
||||
periods = sorted(int(k) for k in subscription.tariff.period_prices.keys())
|
||||
else:
|
||||
periods = settings.get_available_renewal_periods()
|
||||
@@ -128,7 +135,12 @@ async def renew_subscription(
|
||||
detail=f'Cannot renew subscription with status: {_actual_status}',
|
||||
)
|
||||
|
||||
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
|
||||
if (
|
||||
subscription.tariff_id
|
||||
and subscription.tariff
|
||||
and subscription.tariff.is_active
|
||||
and subscription.tariff.period_prices
|
||||
):
|
||||
available_periods = [int(p) for p in subscription.tariff.period_prices.keys()]
|
||||
else:
|
||||
available_periods = settings.get_available_renewal_periods()
|
||||
|
||||
@@ -119,6 +119,18 @@ async def preview_tariff_switch(
|
||||
)
|
||||
upgrade_cost = switch_result.upgrade_cost
|
||||
is_upgrade = switch_result.is_upgrade
|
||||
|
||||
# Проверяем разрешение на смену в данном направлении
|
||||
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Повышение тарифа недоступно',
|
||||
)
|
||||
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Понижение тарифа недоступно',
|
||||
)
|
||||
base_upgrade_cost = switch_result.raw_cost
|
||||
discount_value = switch_result.discount_value
|
||||
period_discount_percent = switch_result.effective_discount_pct
|
||||
@@ -263,11 +275,24 @@ async def switch_tariff(
|
||||
user=user,
|
||||
)
|
||||
upgrade_cost = switch_result.upgrade_cost
|
||||
is_upgrade = switch_result.is_upgrade
|
||||
base_upgrade_cost = switch_result.raw_cost
|
||||
discount_value = switch_result.discount_value
|
||||
period_discount_percent = switch_result.effective_discount_pct
|
||||
new_period_days = switch_result.new_period_days
|
||||
|
||||
# Проверяем разрешение на смену в данном направлении
|
||||
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Повышение тарифа недоступно',
|
||||
)
|
||||
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Понижение тарифа недоступно',
|
||||
)
|
||||
|
||||
# Validate daily price for switching TO daily
|
||||
new_is_daily = getattr(new_tariff, 'is_daily', False)
|
||||
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
|
||||
|
||||
@@ -20,6 +20,7 @@ from ..schemas.tickets import (
|
||||
TicketCreateRequest,
|
||||
TicketDetailResponse,
|
||||
TicketListResponse,
|
||||
TicketMediaItem,
|
||||
TicketMessageCreateRequest,
|
||||
TicketMessageResponse,
|
||||
TicketResponse,
|
||||
@@ -33,14 +34,23 @@ router = APIRouter(prefix='/tickets', tags=['Cabinet Tickets'])
|
||||
|
||||
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
|
||||
"""Convert TicketMessage to response."""
|
||||
raw_items = getattr(message, 'media_items', None) or None
|
||||
items = None
|
||||
if raw_items:
|
||||
try:
|
||||
items = [TicketMediaItem(**it) for it in raw_items]
|
||||
except (TypeError, KeyError, ValueError) as exc:
|
||||
logger.warning('Failed to parse media_items', message_id=message.id, error=str(exc))
|
||||
items = None
|
||||
return TicketMessageResponse(
|
||||
id=message.id,
|
||||
message_text=message.message_text or '',
|
||||
is_from_admin=message.is_from_admin,
|
||||
has_media=bool(message.media_file_id),
|
||||
has_media=bool(message.media_file_id) or bool(items),
|
||||
media_type=message.media_type,
|
||||
media_file_id=message.media_file_id,
|
||||
media_caption=message.media_caption,
|
||||
media_items=items,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
@@ -143,15 +153,30 @@ async def create_ticket(
|
||||
db.add(ticket)
|
||||
await db.flush()
|
||||
|
||||
# Resolve media payload
|
||||
items_payload = None
|
||||
primary_type = request.media_type
|
||||
primary_file_id = request.media_file_id
|
||||
primary_caption = request.media_caption
|
||||
if getattr(request, 'media_items', None):
|
||||
items_payload = [it.model_dump() for it in request.media_items]
|
||||
first = request.media_items[0]
|
||||
primary_type = first.type
|
||||
primary_file_id = first.file_id
|
||||
primary_caption = primary_caption or first.caption
|
||||
|
||||
# Create initial message with optional media
|
||||
has_media = bool(primary_file_id)
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user.id,
|
||||
message_text=request.message,
|
||||
is_from_admin=False,
|
||||
media_type=request.media_type,
|
||||
media_file_id=request.media_file_id,
|
||||
media_caption=request.media_caption,
|
||||
has_media=has_media,
|
||||
media_type=primary_type if has_media else None,
|
||||
media_file_id=primary_file_id if has_media else None,
|
||||
media_caption=primary_caption if has_media else None,
|
||||
media_items=items_payload,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
@@ -259,15 +284,30 @@ async def add_ticket_message(
|
||||
detail='Replies to this ticket are blocked',
|
||||
)
|
||||
|
||||
# Resolve media payload
|
||||
items_payload = None
|
||||
primary_type = request.media_type
|
||||
primary_file_id = request.media_file_id
|
||||
primary_caption = request.media_caption
|
||||
if getattr(request, 'media_items', None):
|
||||
items_payload = [it.model_dump() for it in request.media_items]
|
||||
first = request.media_items[0]
|
||||
primary_type = first.type
|
||||
primary_file_id = first.file_id
|
||||
primary_caption = primary_caption or first.caption
|
||||
|
||||
# Create message with optional media
|
||||
has_media = bool(primary_file_id)
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user.id,
|
||||
message_text=request.message,
|
||||
is_from_admin=False,
|
||||
media_type=request.media_type,
|
||||
media_file_id=request.media_file_id,
|
||||
media_caption=request.media_caption,
|
||||
has_media=has_media,
|
||||
media_type=primary_type if has_media else None,
|
||||
media_file_id=primary_file_id if has_media else None,
|
||||
media_caption=primary_caption if has_media else None,
|
||||
media_items=items_payload,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
@@ -286,8 +326,8 @@ async def add_ticket_message(
|
||||
ticket,
|
||||
request.message,
|
||||
db,
|
||||
media_file_id=request.media_file_id,
|
||||
media_type=request.media_type,
|
||||
media_file_id=primary_file_id,
|
||||
media_type=primary_type,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Error notifying admins about ticket reply from cabinet', error=e)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Schemas for admin bulk actions."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class BulkActionType(StrEnum):
|
||||
EXTEND_SUBSCRIPTION = 'extend_subscription'
|
||||
CANCEL_SUBSCRIPTION = 'cancel_subscription'
|
||||
ACTIVATE_SUBSCRIPTION = 'activate_subscription'
|
||||
CHANGE_TARIFF = 'change_tariff'
|
||||
ADD_DAYS = 'add_days'
|
||||
ADD_TRAFFIC = 'add_traffic'
|
||||
ADD_BALANCE = 'add_balance'
|
||||
ASSIGN_PROMO_GROUP = 'assign_promo_group'
|
||||
GRANT_SUBSCRIPTION = 'grant_subscription'
|
||||
SET_DEVICES = 'set_devices'
|
||||
DELETE_SUBSCRIPTION = 'delete_subscription'
|
||||
DELETE_USER = 'delete_user'
|
||||
|
||||
|
||||
class BulkActionParams(BaseModel):
|
||||
days: int | None = Field(None, ge=1, le=3650)
|
||||
tariff_id: int | None = Field(None, gt=0)
|
||||
traffic_gb: int | None = Field(None, ge=1, le=10000)
|
||||
amount_kopeks: int | None = Field(None, ge=1, le=2_000_000_000)
|
||||
balance_description: str = Field(default='Массовое начисление баланса', max_length=500)
|
||||
promo_group_id: int | None = None
|
||||
device_limit: int | None = Field(None, ge=1, le=50)
|
||||
delete_from_panel: bool = Field(default=True)
|
||||
|
||||
|
||||
class BulkSubscriptionInfo(BaseModel):
|
||||
id: int
|
||||
tariff_id: int | None = None
|
||||
tariff_name: str | None = None
|
||||
status: str
|
||||
days_remaining: int
|
||||
traffic_used_gb: float = 0
|
||||
traffic_limit_gb: int = 0
|
||||
device_limit: int = 0
|
||||
|
||||
|
||||
class BulkExecuteRequest(BaseModel):
|
||||
action: BulkActionType
|
||||
user_ids: list[int] | None = Field(None, min_length=1, max_length=500)
|
||||
subscription_ids: list[int] | None = Field(None, min_length=1, max_length=2000)
|
||||
params: BulkActionParams = Field(default_factory=BulkActionParams)
|
||||
dry_run: bool = Field(default=False, description='Preview only, no mutations')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _exactly_one_target(self):
|
||||
has_users = self.user_ids is not None
|
||||
has_subs = self.subscription_ids is not None
|
||||
if has_users == has_subs:
|
||||
raise ValueError('Exactly one of user_ids or subscription_ids must be provided')
|
||||
return self
|
||||
|
||||
|
||||
class BulkUserResult(BaseModel):
|
||||
user_id: int
|
||||
subscription_id: int | None = None
|
||||
success: bool
|
||||
message: str
|
||||
username: str | None = None
|
||||
subscriptions: list[BulkSubscriptionInfo] | None = None
|
||||
|
||||
|
||||
class BulkExecuteResponse(BaseModel):
|
||||
action: str
|
||||
total: int
|
||||
success_count: int
|
||||
error_count: int
|
||||
skipped_count: int
|
||||
dry_run: bool
|
||||
results: list[BulkUserResult]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Schemas for info pages in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class InfoPageResponse(BaseModel):
|
||||
"""Full info page response."""
|
||||
|
||||
id: int
|
||||
slug: str
|
||||
title: dict[str, str]
|
||||
content: dict[str, str]
|
||||
page_type: str = 'page'
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
icon: str | None = None
|
||||
replaces_tab: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InfoPageListItem(BaseModel):
|
||||
"""Compact info page for list views."""
|
||||
|
||||
id: int
|
||||
slug: str
|
||||
title: dict[str, str]
|
||||
page_type: str = 'page'
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
icon: str | None = None
|
||||
replaces_tab: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InfoPageCreateRequest(BaseModel):
|
||||
"""Request to create an info page."""
|
||||
|
||||
slug: str = Field(min_length=1, max_length=200, pattern=r'^[a-z0-9\-]+$')
|
||||
title: dict[str, str] = Field(default_factory=dict)
|
||||
content: dict[str, str] = Field(default_factory=dict)
|
||||
page_type: str = Field(default='page', pattern=r'^(page|faq)$')
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
icon: str | None = Field(None, max_length=50)
|
||||
replaces_tab: str | None = Field(None, pattern=r'^(faq|rules|privacy|offer)$')
|
||||
|
||||
|
||||
class InfoPageUpdateRequest(BaseModel):
|
||||
"""Request to update an info page."""
|
||||
|
||||
slug: str | None = Field(None, min_length=1, max_length=200, pattern=r'^[a-z0-9\-]+$')
|
||||
title: dict[str, str] | None = None
|
||||
content: dict[str, str] | None = None
|
||||
page_type: str | None = Field(None, pattern=r'^(page|faq)$')
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
icon: str | None = Field(None, max_length=50)
|
||||
replaces_tab: str | None = Field(None, pattern=r'^(faq|rules|privacy|offer)$')
|
||||
|
||||
|
||||
class ReorderItem(BaseModel):
|
||||
"""Single item in a reorder request."""
|
||||
|
||||
id: int
|
||||
sort_order: int = Field(ge=0)
|
||||
|
||||
|
||||
class ReorderRequest(BaseModel):
|
||||
"""Request to bulk-reorder info pages."""
|
||||
|
||||
items: list[ReorderItem] = Field(..., min_length=1)
|
||||
@@ -2,7 +2,50 @@
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
ALLOWED_MEDIA_TYPES = {'photo', 'video', 'document'}
|
||||
MAX_MEDIA_ITEMS = 10
|
||||
|
||||
|
||||
class TicketMediaItem(BaseModel):
|
||||
"""Single media attachment in a ticket message."""
|
||||
|
||||
type: str = Field(..., description='Media type: photo, video, or document')
|
||||
file_id: str = Field(..., max_length=255, description='Telegram file_id')
|
||||
caption: str | None = Field(None, max_length=1000, description='Optional caption')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_type(self) -> 'TicketMediaItem':
|
||||
if self.type not in ALLOWED_MEDIA_TYPES:
|
||||
raise ValueError(f'type must be one of: {sorted(ALLOWED_MEDIA_TYPES)}')
|
||||
return self
|
||||
|
||||
|
||||
def _validate_media_bundle(
|
||||
media_type: str | None,
|
||||
media_file_id: str | None,
|
||||
media_items: list[TicketMediaItem] | None,
|
||||
) -> None:
|
||||
"""Shared validator for media-attached request bodies."""
|
||||
if media_items is not None:
|
||||
if len(media_items) == 0:
|
||||
raise ValueError('media_items must not be empty (send null instead)')
|
||||
if len(media_items) > MAX_MEDIA_ITEMS:
|
||||
raise ValueError(f'media_items cannot exceed {MAX_MEDIA_ITEMS} entries')
|
||||
if media_file_id and media_file_id != media_items[0].file_id:
|
||||
raise ValueError('legacy media_file_id must match media_items[0].file_id')
|
||||
if media_type and media_type != media_items[0].type:
|
||||
raise ValueError('legacy media_type must match media_items[0].type')
|
||||
return
|
||||
|
||||
if media_file_id and not media_type:
|
||||
raise ValueError('media_type is required when media_file_id is provided')
|
||||
if media_type and not media_file_id:
|
||||
raise ValueError('media_file_id is required when media_type is provided')
|
||||
if media_type and media_type not in ALLOWED_MEDIA_TYPES:
|
||||
raise ValueError(f'media_type must be one of: {sorted(ALLOWED_MEDIA_TYPES)}')
|
||||
|
||||
|
||||
class TicketMessageResponse(BaseModel):
|
||||
@@ -15,6 +58,7 @@ class TicketMessageResponse(BaseModel):
|
||||
media_type: str | None = None
|
||||
media_file_id: str | None = None
|
||||
media_caption: str | None = None
|
||||
media_items: list[TicketMediaItem] | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
@@ -69,16 +113,36 @@ class TicketCreateRequest(BaseModel):
|
||||
"""Request to create a new ticket."""
|
||||
|
||||
title: str = Field(..., min_length=3, max_length=255, description='Ticket title')
|
||||
message: str = Field(..., min_length=10, max_length=4000, description='Initial message')
|
||||
message: str = Field(default='', max_length=4000, description='Initial message')
|
||||
media_type: str | None = Field(None, description='Media type: photo, video, document')
|
||||
media_file_id: str | None = Field(None, description='Telegram file_id of uploaded media')
|
||||
media_caption: str | None = Field(None, max_length=1000, description='Media caption')
|
||||
media_items: list[TicketMediaItem] | None = Field(None, description='Multi-media attachments')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_has_content(self) -> 'TicketCreateRequest':
|
||||
_validate_media_bundle(self.media_type, self.media_file_id, self.media_items)
|
||||
has_text = bool(self.message.strip())
|
||||
has_media = bool(self.media_file_id) or bool(self.media_items)
|
||||
if not has_text and not has_media:
|
||||
raise ValueError('message or media is required')
|
||||
return self
|
||||
|
||||
|
||||
class TicketMessageCreateRequest(BaseModel):
|
||||
"""Request to add message to ticket."""
|
||||
|
||||
message: str = Field(..., min_length=1, max_length=4000, description='Message text')
|
||||
message: str = Field(default='', max_length=4000, description='Message text')
|
||||
media_type: str | None = Field(None, description='Media type: photo, video, document')
|
||||
media_file_id: str | None = Field(None, description='Telegram file_id of uploaded media')
|
||||
media_caption: str | None = Field(None, max_length=1000, description='Media caption')
|
||||
media_items: list[TicketMediaItem] | None = Field(None, description='Multi-media attachments')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_has_content(self) -> 'TicketMessageCreateRequest':
|
||||
_validate_media_bundle(self.media_type, self.media_file_id, self.media_items)
|
||||
has_text = bool(self.message.strip())
|
||||
has_media = bool(self.media_file_id) or bool(self.media_items)
|
||||
if not has_text and not has_media:
|
||||
raise ValueError('message or media is required')
|
||||
return self
|
||||
|
||||
@@ -82,6 +82,20 @@ class UserPromoGroupInfo(BaseModel):
|
||||
# === User List ===
|
||||
|
||||
|
||||
class SubscriptionListItem(BaseModel):
|
||||
"""Compact subscription info for user list (multi-tariff mode)."""
|
||||
|
||||
id: int
|
||||
tariff_id: int | None = None
|
||||
tariff_name: str | None = None
|
||||
status: str
|
||||
end_date: datetime | None = None
|
||||
days_remaining: int = 0
|
||||
traffic_used_gb: float = 0
|
||||
traffic_limit_gb: int = 0
|
||||
device_limit: int = 0
|
||||
|
||||
|
||||
class UserListItem(BaseModel):
|
||||
"""User item in list."""
|
||||
|
||||
@@ -102,6 +116,15 @@ class UserListItem(BaseModel):
|
||||
subscription_status: str | None = None
|
||||
subscription_is_trial: bool = False
|
||||
subscription_end_date: datetime | None = None
|
||||
tariff_id: int | None = None
|
||||
tariff_name: str | None = None
|
||||
traffic_used_gb: float = 0
|
||||
traffic_limit_gb: int = 0
|
||||
device_limit: int = 0
|
||||
days_remaining: int = 0
|
||||
|
||||
# All subscriptions (multi-tariff)
|
||||
subscriptions: list[SubscriptionListItem] = []
|
||||
|
||||
# Promo group
|
||||
promo_group_id: int | None = None
|
||||
|
||||
+58
-1
@@ -148,6 +148,9 @@ class Settings(BaseSettings):
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = 'MONTH'
|
||||
RESET_TRAFFIC_ON_PAYMENT: bool = False
|
||||
RESET_TRAFFIC_ON_TARIFF_SWITCH: bool = True
|
||||
RESET_DEVICES_ON_RENEWAL: bool = False
|
||||
TARIFF_SWITCH_UPGRADE_ENABLED: bool = True
|
||||
TARIFF_SWITCH_DOWNGRADE_ENABLED: bool = True
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
|
||||
TRIAL_WARNING_HOURS: int = 2
|
||||
@@ -564,6 +567,21 @@ class Settings(BaseSettings):
|
||||
KASSA_AI_SBERPAY_ENABLED: bool = False # SberPay — payment_system_id=43
|
||||
KASSA_AI_SBERPAY_DISPLAY_NAME: str = 'SberPay (KassaAI)'
|
||||
|
||||
# ── Yandex Metrika offline conversions (server → mc.yandex.ru/collect) ──
|
||||
YANDEX_OFFLINE_CONV_ENABLED: bool = False
|
||||
YANDEX_OFFLINE_CONV_COUNTER_ID: str = ''
|
||||
YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET: str = ''
|
||||
YANDEX_OFFLINE_CONV_START_PREFIX: str = 'utm_ya_'
|
||||
YANDEX_OFFLINE_CONV_DL: str = ''
|
||||
YANDEX_OFFLINE_CONV_DT: str = ''
|
||||
YANDEX_OFFLINE_CONV_CURRENCY: str = 'RUB'
|
||||
|
||||
# ── S2S Postback (server-to-server affiliate notifications) ──
|
||||
S2S_POSTBACK_ENABLED: bool = False
|
||||
S2S_POSTBACK_REGISTRATION_URL: str = ''
|
||||
S2S_POSTBACK_TRIAL_URL: str = ''
|
||||
S2S_POSTBACK_PURCHASE_URL: str = ''
|
||||
|
||||
# RioPay (api.riopay.online) v2.0.1
|
||||
RIOPAY_ENABLED: bool = False
|
||||
RIOPAY_API_TOKEN: str | None = None # x-api-token header
|
||||
@@ -611,6 +629,23 @@ class Settings(BaseSettings):
|
||||
ROLLYPAY_WEBHOOK_PATH: str = '/rollypay-webhook'
|
||||
ROLLYPAY_RETURN_URL: str | None = None
|
||||
|
||||
# Overpay (pay.overpay.io)
|
||||
OVERPAY_ENABLED: bool = False
|
||||
OVERPAY_API_URL: str = 'https://api.overpay.io'
|
||||
OVERPAY_USERNAME: str | None = None
|
||||
OVERPAY_PASSWORD: str | None = None
|
||||
OVERPAY_PROJECT_ID: str | None = None
|
||||
OVERPAY_P12_PATH: str | None = None
|
||||
OVERPAY_P12_PASSPHRASE: str | None = None
|
||||
OVERPAY_DISPLAY_NAME: str = 'Overpay'
|
||||
OVERPAY_CURRENCY: str = 'RUB'
|
||||
OVERPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
OVERPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||
OVERPAY_WEBHOOK_PATH: str = '/overpay-webhook'
|
||||
OVERPAY_RETURN_URL: str | None = None
|
||||
OVERPAY_LIFETIME_MINUTES: int = 1440
|
||||
OVERPAY_PAYMENT_METHODS: str = 'card,fps'
|
||||
|
||||
# AuraPay (aurapay.tech)
|
||||
AURAPAY_ENABLED: bool = False
|
||||
AURAPAY_API_KEY: str | None = None # X-ApiKey header
|
||||
@@ -768,6 +803,7 @@ class Settings(BaseSettings):
|
||||
WEBHOOK_URL: str | None = None
|
||||
WEBHOOK_PATH: str = '/webhook'
|
||||
WEBHOOK_SECRET_TOKEN: str | None = None
|
||||
WEBHOOK_IP: str | None = None # IP адрес для setWebhook, чтобы Telegram не резолвил домен
|
||||
WEBHOOK_DROP_PENDING_UPDATES: bool = True
|
||||
WEBHOOK_MAX_QUEUE_SIZE: int = 1024
|
||||
WEBHOOK_WORKERS: int = 4
|
||||
@@ -1231,7 +1267,13 @@ class Settings(BaseSettings):
|
||||
if not sanitized_username:
|
||||
sanitized_username = _sanitize(f'user_{identifier}')
|
||||
|
||||
return sanitized_username[:36].strip('_-') or 'user'
|
||||
result = sanitized_username[:36].strip('_-') or 'user'
|
||||
|
||||
# RemnaWave требует username минимум 3 символа
|
||||
if len(result) < 3:
|
||||
result = f'{result}_{identifier}'[:36].strip('_-')
|
||||
|
||||
return result or 'user'
|
||||
|
||||
@staticmethod
|
||||
def parse_daily_time_list(raw_value: str | None) -> list[time]:
|
||||
@@ -2029,6 +2071,21 @@ class Settings(BaseSettings):
|
||||
def get_rollypay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_rollypay_display_name())
|
||||
|
||||
def is_overpay_enabled(self) -> bool:
|
||||
return (
|
||||
self.OVERPAY_ENABLED
|
||||
and self.OVERPAY_USERNAME is not None
|
||||
and self.OVERPAY_PASSWORD is not None
|
||||
and self.OVERPAY_PROJECT_ID is not None
|
||||
)
|
||||
|
||||
def get_overpay_display_name(self) -> str:
|
||||
name = (self.OVERPAY_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Overpay'
|
||||
|
||||
def get_overpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_overpay_display_name())
|
||||
|
||||
def is_aurapay_enabled(self) -> bool:
|
||||
return (
|
||||
self.AURAPAY_ENABLED
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""CRUD operations for info pages."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import InfoPage
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Fields that can be set via update_info_page
|
||||
_ALLOWED_UPDATE_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
'slug',
|
||||
'title',
|
||||
'content',
|
||||
'page_type',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
'icon',
|
||||
'replaces_tab',
|
||||
}
|
||||
)
|
||||
|
||||
# Fields that can be explicitly set to None
|
||||
_NULLABLE_UPDATE_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
'icon',
|
||||
'replaces_tab',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def create_info_page(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
slug: str,
|
||||
title: dict[str, str],
|
||||
content: dict[str, str],
|
||||
page_type: str = 'page',
|
||||
is_active: bool = True,
|
||||
sort_order: int = 0,
|
||||
icon: str | None = None,
|
||||
replaces_tab: str | None = None,
|
||||
) -> InfoPage:
|
||||
"""Create a new info page.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if slug is not unique (caller must handle).
|
||||
"""
|
||||
page = InfoPage(
|
||||
slug=slug,
|
||||
title=title,
|
||||
content=content,
|
||||
page_type=page_type,
|
||||
is_active=is_active,
|
||||
sort_order=sort_order,
|
||||
icon=icon,
|
||||
replaces_tab=replaces_tab,
|
||||
)
|
||||
|
||||
db.add(page)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info('Created info page', page_id=page.id, slug=page.slug)
|
||||
return page
|
||||
|
||||
|
||||
async def get_info_page_by_id(db: AsyncSession, page_id: int) -> InfoPage | None:
|
||||
"""Get an info page by ID."""
|
||||
result = await db.execute(select(InfoPage).where(InfoPage.id == page_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_info_page_by_slug(db: AsyncSession, slug: str) -> InfoPage | None:
|
||||
"""Get an info page by slug."""
|
||||
result = await db.execute(select(InfoPage).where(InfoPage.slug == slug))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_all_info_pages(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
page_type: str | None = None,
|
||||
) -> list[InfoPage]:
|
||||
"""Get all info pages, ordered by sort_order ascending."""
|
||||
stmt = select(InfoPage)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(InfoPage.is_active.is_(True))
|
||||
if page_type is not None:
|
||||
stmt = stmt.where(InfoPage.page_type == page_type)
|
||||
|
||||
stmt = stmt.order_by(InfoPage.sort_order.asc(), InfoPage.id.asc())
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_info_page(
|
||||
db: AsyncSession,
|
||||
page_id: int,
|
||||
**kwargs: Any,
|
||||
) -> InfoPage | None:
|
||||
"""Update an info page. Only whitelisted fields are applied.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if slug conflicts with another page (caller must handle).
|
||||
"""
|
||||
update_data: dict[str, Any] = {}
|
||||
for key, value in kwargs.items():
|
||||
if key not in _ALLOWED_UPDATE_FIELDS:
|
||||
continue
|
||||
if value is None and key not in _NULLABLE_UPDATE_FIELDS:
|
||||
continue
|
||||
update_data[key] = value
|
||||
|
||||
if not update_data:
|
||||
return await get_info_page_by_id(db, page_id)
|
||||
|
||||
update_data['updated_at'] = datetime.now(UTC)
|
||||
|
||||
await db.execute(update(InfoPage).where(InfoPage.id == page_id).values(**update_data))
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
page = await get_info_page_by_id(db, page_id)
|
||||
if page:
|
||||
logger.info(
|
||||
'Updated info page',
|
||||
page_id=page_id,
|
||||
updated_fields=list(update_data.keys()),
|
||||
)
|
||||
return page
|
||||
|
||||
|
||||
async def delete_info_page(db: AsyncSession, page_id: int) -> None:
|
||||
"""Delete an info page."""
|
||||
await db.execute(delete(InfoPage).where(InfoPage.id == page_id))
|
||||
await db.commit()
|
||||
|
||||
logger.info('Deleted info page', page_id=page_id)
|
||||
|
||||
|
||||
async def get_tab_replacements(db: AsyncSession) -> dict[str, str | None]:
|
||||
"""Return a mapping of tab name to info page slug for active pages with replaces_tab set.
|
||||
|
||||
Returns dict like ``{'faq': 'my-custom-faq', 'rules': None, 'privacy': None, 'offer': None}``.
|
||||
"""
|
||||
result_map: dict[str, str | None] = {
|
||||
'faq': None,
|
||||
'rules': None,
|
||||
'privacy': None,
|
||||
'offer': None,
|
||||
}
|
||||
|
||||
stmt = select(InfoPage).where(
|
||||
InfoPage.is_active.is_(True),
|
||||
InfoPage.replaces_tab.isnot(None),
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
for page in result.scalars().all():
|
||||
if page.replaces_tab in result_map:
|
||||
result_map[page.replaces_tab] = page.slug
|
||||
|
||||
return result_map
|
||||
|
||||
|
||||
async def clear_replaces_tab(db: AsyncSession, tab: str, *, exclude_page_id: int | None = None) -> None:
|
||||
"""Clear replaces_tab for all pages that currently replace the given tab.
|
||||
|
||||
Optionally exclude a specific page (the one being saved).
|
||||
"""
|
||||
stmt = (
|
||||
update(InfoPage)
|
||||
.where(
|
||||
InfoPage.replaces_tab == tab,
|
||||
)
|
||||
.values(replaces_tab=None, updated_at=datetime.now(UTC))
|
||||
)
|
||||
if exclude_page_id is not None:
|
||||
stmt = stmt.where(InfoPage.id != exclude_page_id)
|
||||
|
||||
await db.execute(stmt)
|
||||
|
||||
|
||||
async def reorder_info_pages(db: AsyncSession, items: list[dict]) -> None:
|
||||
"""Bulk update sort_order for info pages.
|
||||
|
||||
Each item must have ``id`` and ``sort_order`` attributes.
|
||||
"""
|
||||
for item in items:
|
||||
page_id = item.id if hasattr(item, 'id') else item.get('id')
|
||||
sort_order = item.sort_order if hasattr(item, 'sort_order') else item.get('sort_order')
|
||||
if page_id is None or sort_order is None:
|
||||
continue
|
||||
await db.execute(
|
||||
update(InfoPage).where(InfoPage.id == page_id).values(sort_order=sort_order, updated_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info('Reordered info pages', count=len(items))
|
||||
@@ -76,6 +76,11 @@ _LANDING_UPDATABLE_FIELDS = frozenset(
|
||||
'discount_ends_at',
|
||||
'discount_badge_text',
|
||||
'background_config',
|
||||
'sticky_pay_button',
|
||||
'analytics_view_enabled',
|
||||
'analytics_view_goal',
|
||||
'analytics_click_enabled',
|
||||
'analytics_click_goal',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""CRUD операции для платежей Overpay."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import OverpayPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_overpay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
overpay_payment_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> OverpayPayment:
|
||||
"""Создает запись о платеже Overpay."""
|
||||
payment = OverpayPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
overpay_payment_id=overpay_payment_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж Overpay', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_overpay_payment_by_order_id(db: AsyncSession, order_id: str) -> OverpayPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(OverpayPayment).where(OverpayPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_overpay_payment_by_overpay_id(db: AsyncSession, overpay_payment_id: str) -> OverpayPayment | None:
|
||||
"""Получает платеж по ID от Overpay."""
|
||||
result = await db.execute(select(OverpayPayment).where(OverpayPayment.overpay_payment_id == overpay_payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_overpay_payment_by_id(db: AsyncSession, payment_id: int) -> OverpayPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(OverpayPayment).where(OverpayPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_overpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> OverpayPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(OverpayPayment)
|
||||
.where(OverpayPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_overpay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: OverpayPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
overpay_payment_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> OverpayPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if overpay_payment_id is not None:
|
||||
payment.overpay_payment_id = overpay_payment_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлен статус платежа Overpay',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_overpay_payments(db: AsyncSession, user_id: int) -> list[OverpayPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(OverpayPayment).where(
|
||||
OverpayPayment.user_id == user_id,
|
||||
OverpayPayment.status == 'pending',
|
||||
OverpayPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_overpay_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[OverpayPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(OverpayPayment).where(
|
||||
OverpayPayment.status == 'pending',
|
||||
OverpayPayment.is_paid == False,
|
||||
OverpayPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_overpay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: OverpayPayment,
|
||||
transaction_id: int,
|
||||
) -> OverpayPayment:
|
||||
"""Связывает платеж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -33,7 +33,15 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
async def _get_default_promo_group_id(db: AsyncSession) -> int | None:
|
||||
result = await db.execute(select(PromoGroup.id).where(PromoGroup.is_default.is_(True)).limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
default_id = result.scalar_one_or_none()
|
||||
if default_id is not None:
|
||||
return default_id
|
||||
|
||||
# На пустой БД дефолтной промогруппы нет — создаём автоматически
|
||||
from app.database.crud.user import _get_or_create_default_promo_group
|
||||
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
return default_group.id
|
||||
|
||||
|
||||
async def create_server_squad(
|
||||
@@ -155,6 +163,20 @@ async def get_available_server_squads(
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def get_effective_tariff_squad_uuids(
|
||||
db: AsyncSession,
|
||||
allowed_squads: Sequence[str] | None,
|
||||
) -> list[str]:
|
||||
"""Resolve tariff squads, treating an empty list as "all available squads"."""
|
||||
|
||||
normalized = [str(squad_uuid) for squad_uuid in (allowed_squads or []) if squad_uuid]
|
||||
if normalized:
|
||||
return list(dict.fromkeys(normalized))
|
||||
|
||||
available = await get_available_server_squads(db)
|
||||
return [squad.squad_uuid for squad in available if squad.squad_uuid]
|
||||
|
||||
|
||||
async def get_active_server_squads(db: AsyncSession) -> list[ServerSquad]:
|
||||
"""Возвращает список активных серверов, доступных для подключения."""
|
||||
|
||||
|
||||
@@ -142,8 +142,8 @@ async def create_trial_subscription(
|
||||
if device_limit is None:
|
||||
device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
|
||||
# Если переданы connected_squads, используем их
|
||||
# Иначе используем squad_uuid или получаем случайный
|
||||
# Если переданы connected_squads, используем их.
|
||||
# Иначе используем squad_uuid или все доступные сквады по умолчанию.
|
||||
final_squads = []
|
||||
if connected_squads:
|
||||
final_squads = connected_squads
|
||||
@@ -151,13 +151,14 @@ async def create_trial_subscription(
|
||||
final_squads = [squad_uuid]
|
||||
else:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
|
||||
random_squad = await get_random_trial_squad_uuid(db)
|
||||
if random_squad:
|
||||
final_squads = [random_squad]
|
||||
final_squads = await get_effective_tariff_squad_uuids(db, None)
|
||||
if final_squads:
|
||||
logger.debug(
|
||||
'Выбран сквад для триальной подписки пользователя', random_squad=random_squad, user_id=user_id
|
||||
'Выбраны дефолтные сквады для триальной подписки пользователя',
|
||||
final_squads=final_squads,
|
||||
user_id=user_id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Не удалось получить сквад для триальной подписки пользователя', user_id=user_id, error=error)
|
||||
|
||||
@@ -25,6 +25,7 @@ class TicketCRUD:
|
||||
media_type: str | None = None,
|
||||
media_file_id: str | None = None,
|
||||
media_caption: str | None = None,
|
||||
media_items: list[dict] | None = None,
|
||||
) -> Ticket:
|
||||
"""Создать новый тикет с первым сообщением"""
|
||||
ticket = Ticket(user_id=user_id, title=title, status=TicketStatus.OPEN.value, priority=priority)
|
||||
@@ -37,10 +38,11 @@ class TicketCRUD:
|
||||
user_id=user_id,
|
||||
message_text=message_text,
|
||||
is_from_admin=False,
|
||||
has_media=bool(media_type and media_file_id),
|
||||
has_media=bool(media_type and media_file_id) or bool(media_items),
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
media_items=media_items,
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
@@ -381,6 +383,7 @@ class TicketMessageCRUD:
|
||||
media_type: str | None = None,
|
||||
media_file_id: str | None = None,
|
||||
media_caption: str | None = None,
|
||||
media_items: list[dict] | None = None,
|
||||
) -> TicketMessage:
|
||||
"""Добавить сообщение в тикет"""
|
||||
message = TicketMessage(
|
||||
@@ -388,10 +391,11 @@ class TicketMessageCRUD:
|
||||
user_id=user_id,
|
||||
message_text=message_text,
|
||||
is_from_admin=is_from_admin,
|
||||
has_media=bool(media_type and media_file_id),
|
||||
has_media=bool(media_type and media_file_id) or bool(media_items),
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
media_items=media_items,
|
||||
)
|
||||
|
||||
db.add(message)
|
||||
|
||||
@@ -230,17 +230,17 @@ async def get_user_transactions_count(
|
||||
|
||||
|
||||
async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
|
||||
"""Sum of personal spending for promo group auto-assignment.
|
||||
|
||||
Only counts SUBSCRIPTION_PAYMENT (user's own subscriptions).
|
||||
GIFT_PAYMENT is excluded — buying a gift for someone else is not personal spending.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
TransactionType.GIFT_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
+144
-26
@@ -3,7 +3,7 @@ import string
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, case, func, nullslast, or_, select, text
|
||||
from sqlalchemy import and_, case, exists, func, nullslast, or_, select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -13,6 +13,8 @@ from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
|
||||
from app.database.crud.promo_group import get_default_promo_group
|
||||
from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
PaymentMethod,
|
||||
PromoGroup,
|
||||
Subscription,
|
||||
@@ -710,30 +712,52 @@ async def subtract_user_balance(
|
||||
await db.refresh(user)
|
||||
|
||||
if consume_promo_offer and log_context:
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
offer_id=log_context.get('offer_id'),
|
||||
action='consumed',
|
||||
source=log_context.get('source'),
|
||||
percent=log_context.get('percent'),
|
||||
effect_type=log_context.get('effect_type'),
|
||||
details=log_context.get('details'),
|
||||
commit=commit,
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to record promo offer consumption log for user', user_id=user.id, log_error=log_error
|
||||
)
|
||||
if commit:
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to rollback session after promo offer consumption log failure',
|
||||
rollback_error=rollback_error,
|
||||
# Пишем лог в ОТДЕЛЬНОЙ сессии, чтобы его commit/rollback не касался
|
||||
# основной сессии caller'а. Иначе rollback в случае фейла логирования
|
||||
# экспайрит объекты сессии и следующее обращение к subscription/user
|
||||
# attrs у caller'а падает с MissingGreenlet.
|
||||
if commit:
|
||||
try:
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as log_db:
|
||||
await log_promo_offer_action(
|
||||
log_db,
|
||||
user_id=user.id,
|
||||
offer_id=log_context.get('offer_id'),
|
||||
action='consumed',
|
||||
source=log_context.get('source'),
|
||||
percent=log_context.get('percent'),
|
||||
effect_type=log_context.get('effect_type'),
|
||||
details=log_context.get('details'),
|
||||
commit=True,
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to record promo offer consumption log for user',
|
||||
user_id=user.id,
|
||||
log_error=log_error,
|
||||
)
|
||||
else:
|
||||
# Caller управляет транзакцией — пишем в его сессию без commit.
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
offer_id=log_context.get('offer_id'),
|
||||
action='consumed',
|
||||
source=log_context.get('source'),
|
||||
percent=log_context.get('percent'),
|
||||
effect_type=log_context.get('effect_type'),
|
||||
details=log_context.get('details'),
|
||||
commit=False,
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to record promo offer consumption log for user',
|
||||
user_id=user.id,
|
||||
log_error=log_error,
|
||||
)
|
||||
|
||||
logger.info('✅ Средства списаны: →', old_balance=old_balance, balance_kopeks=user.balance_kopeks)
|
||||
return True
|
||||
@@ -839,6 +863,11 @@ async def get_users_list(
|
||||
search: str | None = None,
|
||||
email: str | None = None,
|
||||
status: UserStatus | None = None,
|
||||
subscription_status: str | None = None,
|
||||
tariff_ids: list[int] | None = None,
|
||||
promo_group_id: int | None = None,
|
||||
campaign_id: int | None = None,
|
||||
partner_id: int | None = None,
|
||||
order_by_balance: bool = False,
|
||||
order_by_traffic: bool = False,
|
||||
order_by_last_activity: bool = False,
|
||||
@@ -854,6 +883,41 @@ async def get_users_list(
|
||||
if status:
|
||||
query = query.where(User.status == status.value)
|
||||
|
||||
# Subscription-level filters via subquery
|
||||
if subscription_status or tariff_ids:
|
||||
sub_conditions = []
|
||||
if subscription_status:
|
||||
sub_conditions.append(Subscription.status == subscription_status)
|
||||
if tariff_ids:
|
||||
sub_conditions.append(Subscription.tariff_id.in_(tariff_ids))
|
||||
sub_query = select(Subscription.user_id).where(and_(*sub_conditions)).distinct().scalar_subquery()
|
||||
query = query.where(User.id.in_(sub_query))
|
||||
|
||||
if promo_group_id:
|
||||
query = query.where(User.promo_group_id == promo_group_id)
|
||||
|
||||
if campaign_id:
|
||||
query = query.where(
|
||||
exists(
|
||||
select(AdvertisingCampaignRegistration.id).where(
|
||||
AdvertisingCampaignRegistration.user_id == User.id,
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if partner_id:
|
||||
query = query.where(
|
||||
exists(
|
||||
select(AdvertisingCampaignRegistration.id)
|
||||
.join(AdvertisingCampaign, AdvertisingCampaign.id == AdvertisingCampaignRegistration.campaign_id)
|
||||
.where(
|
||||
AdvertisingCampaignRegistration.user_id == User.id,
|
||||
AdvertisingCampaign.partner_user_id == partner_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if search:
|
||||
search_term = f'%{search}%'
|
||||
conditions = [
|
||||
@@ -933,13 +997,55 @@ async def get_users_list(
|
||||
|
||||
|
||||
async def get_users_count(
|
||||
db: AsyncSession, status: UserStatus | None = None, search: str | None = None, email: str | None = None
|
||||
db: AsyncSession,
|
||||
status: UserStatus | None = None,
|
||||
search: str | None = None,
|
||||
email: str | None = None,
|
||||
subscription_status: str | None = None,
|
||||
tariff_ids: list[int] | None = None,
|
||||
promo_group_id: int | None = None,
|
||||
campaign_id: int | None = None,
|
||||
partner_id: int | None = None,
|
||||
) -> int:
|
||||
query = select(func.count(User.id))
|
||||
|
||||
if status:
|
||||
query = query.where(User.status == status.value)
|
||||
|
||||
if subscription_status or tariff_ids:
|
||||
sub_conditions = []
|
||||
if subscription_status:
|
||||
sub_conditions.append(Subscription.status == subscription_status)
|
||||
if tariff_ids:
|
||||
sub_conditions.append(Subscription.tariff_id.in_(tariff_ids))
|
||||
sub_query = select(Subscription.user_id).where(and_(*sub_conditions)).distinct().scalar_subquery()
|
||||
query = query.where(User.id.in_(sub_query))
|
||||
|
||||
if promo_group_id:
|
||||
query = query.where(User.promo_group_id == promo_group_id)
|
||||
|
||||
if campaign_id:
|
||||
query = query.where(
|
||||
exists(
|
||||
select(AdvertisingCampaignRegistration.id).where(
|
||||
AdvertisingCampaignRegistration.user_id == User.id,
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if partner_id:
|
||||
query = query.where(
|
||||
exists(
|
||||
select(AdvertisingCampaignRegistration.id)
|
||||
.join(AdvertisingCampaign, AdvertisingCampaign.id == AdvertisingCampaignRegistration.campaign_id)
|
||||
.where(
|
||||
AdvertisingCampaignRegistration.user_id == User.id,
|
||||
AdvertisingCampaign.partner_user_id == partner_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if search:
|
||||
search_term = f'%{search}%'
|
||||
conditions = [
|
||||
@@ -1090,6 +1196,12 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> list[Us
|
||||
async def get_inactive_users(db: AsyncSession, months: int = 3) -> list[User]:
|
||||
threshold_date = datetime.now(UTC) - timedelta(days=months * 30)
|
||||
|
||||
# Подзапрос: пользователи, у которых есть подписка с end_date >= threshold
|
||||
# (активная или недавно истёкшая) — таких удалять нельзя
|
||||
users_with_recent_subs = (
|
||||
select(Subscription.user_id).where(Subscription.end_date >= threshold_date).distinct().scalar_subquery()
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
@@ -1098,7 +1210,13 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> list[User]:
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(and_(User.last_activity < threshold_date, User.status == UserStatus.ACTIVE.value))
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity < threshold_date,
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
User.id.not_in(users_with_recent_subs),
|
||||
)
|
||||
)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""CRUD operations for yandex_client_id_map table."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import YandexClientIdMap
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def upsert_cid(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
cid: str,
|
||||
source: str = 'web',
|
||||
counter_id: str | None = None,
|
||||
subid: str | None = None,
|
||||
) -> YandexClientIdMap:
|
||||
"""Insert or update Yandex ClientID for a user (race-safe via ON CONFLICT)."""
|
||||
now = datetime.now(UTC)
|
||||
values = {
|
||||
'yandex_cid': cid,
|
||||
'source': source,
|
||||
'updated_at': now,
|
||||
}
|
||||
if counter_id:
|
||||
values['counter_id'] = counter_id
|
||||
if subid:
|
||||
values['subid'] = subid
|
||||
|
||||
stmt = (
|
||||
pg_insert(YandexClientIdMap)
|
||||
.values(user_id=user_id, yandex_cid=cid, source=source, counter_id=counter_id, subid=subid)
|
||||
.on_conflict_do_update(index_elements=['user_id'], set_=values)
|
||||
.returning(YandexClientIdMap)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
await db.flush()
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def get_cid(db: AsyncSession, user_id: int) -> YandexClientIdMap | None:
|
||||
"""Get Yandex ClientID mapping for a user."""
|
||||
result = await db.execute(select(YandexClientIdMap).where(YandexClientIdMap.user_id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def mark_registration_sent(db: AsyncSession, user_id: int) -> None:
|
||||
"""Mark registration event as sent for a user."""
|
||||
await db.execute(
|
||||
update(YandexClientIdMap)
|
||||
.where(YandexClientIdMap.user_id == user_id)
|
||||
.values(registration_sent=True, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def mark_trial_sent(db: AsyncSession, user_id: int) -> None:
|
||||
"""Mark trial event as sent for a user."""
|
||||
await db.execute(
|
||||
update(YandexClientIdMap)
|
||||
.where(YandexClientIdMap.user_id == user_id)
|
||||
.values(trial_sent=True, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def upsert_subid(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
subid: str,
|
||||
source: str = 'web',
|
||||
) -> None:
|
||||
"""Save subid for a user. Updates existing record or creates with placeholder CID."""
|
||||
if not subid or len(subid) > 255:
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
# Try update first (don't create empty CID records)
|
||||
result = await db.execute(
|
||||
update(YandexClientIdMap).where(YandexClientIdMap.user_id == user_id).values(subid=subid, updated_at=now)
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
# No existing record — create with placeholder
|
||||
stmt = (
|
||||
pg_insert(YandexClientIdMap)
|
||||
.values(user_id=user_id, yandex_cid='_subid_only', source=source, subid=subid)
|
||||
.on_conflict_do_update(
|
||||
index_elements=['user_id'],
|
||||
set_={'subid': subid, 'updated_at': now},
|
||||
)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.flush()
|
||||
logger.info('Subid saved', user_id=user_id, subid=subid, source=source)
|
||||
|
||||
|
||||
async def get_subid(db: AsyncSession, user_id: int) -> str | None:
|
||||
"""Get subid for a user."""
|
||||
result = await db.execute(select(YandexClientIdMap.subid).where(YandexClientIdMap.user_id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
@@ -164,6 +164,7 @@ class PaymentMethod(Enum):
|
||||
SEVERPAY = 'severpay'
|
||||
PAYPEAR = 'paypear'
|
||||
ROLLYPAY = 'rollypay'
|
||||
OVERPAY = 'overpay'
|
||||
AURAPAY = 'aurapay'
|
||||
MANUAL = 'manual'
|
||||
BALANCE = 'balance'
|
||||
@@ -1005,6 +1006,68 @@ class RollyPayPayment(Base):
|
||||
return f'<RollyPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class OverpayPayment(Base):
|
||||
"""Платежи через Overpay (pay.overpay.io)."""
|
||||
|
||||
__tablename__ = 'overpay_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
overpay_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Overpay
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_method = Column(String(32), nullable=True)
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='overpay_payments')
|
||||
transaction = relationship('Transaction', backref='overpay_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'canceled', 'chargeback', 'amount_mismatch']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<OverpayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class AuraPayPayment(Base):
|
||||
"""Платежи через AuraPay (aurapay.tech)."""
|
||||
|
||||
@@ -2836,6 +2899,8 @@ class TicketMessage(Base):
|
||||
media_type = Column(String(20), nullable=True) # photo, video, document, voice, etc.
|
||||
media_file_id = Column(String(255), nullable=True)
|
||||
media_caption = Column(Text, nullable=True)
|
||||
# Multi-media gallery (photos/videos/documents bundled in one bubble)
|
||||
media_items = Column(JSONB, nullable=True)
|
||||
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
|
||||
@@ -3457,6 +3522,13 @@ class LandingPage(Base):
|
||||
background_config = Column(
|
||||
JSON, nullable=True
|
||||
) # AnimationConfig: {enabled, type, settings, opacity, blur, reducedOnMobile}
|
||||
# Sticky pay button on mobile (full-width fixed bottom)
|
||||
sticky_pay_button = Column(Boolean, nullable=False, default=False, server_default=text('false'))
|
||||
# Yandex Metrika landing-level conversion goals
|
||||
analytics_view_enabled = Column(Boolean, nullable=False, default=False, server_default=text('false'))
|
||||
analytics_view_goal = Column(String(64), nullable=True)
|
||||
analytics_click_enabled = Column(Boolean, nullable=False, default=False, server_default=text('false'))
|
||||
analytics_click_goal = Column(String(64), nullable=True)
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -3519,6 +3591,10 @@ class GuestPurchase(Base):
|
||||
retry_count = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
receipt_uuid = Column(String(255), nullable=True, index=True)
|
||||
receipt_created_at = Column(AwareDateTime(), nullable=True)
|
||||
# Yandex Metrika offline conversions: client identifier + traffic source tags
|
||||
yandex_cid = Column(String(128), nullable=True)
|
||||
subid = Column(String(255), nullable=True)
|
||||
referrer = Column(String(500), nullable=True)
|
||||
|
||||
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
|
||||
tariff = relationship('Tariff', lazy='selectin')
|
||||
@@ -3600,3 +3676,43 @@ class NewsTag(Base):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<NewsTag id={self.id} name='{self.name}'>"
|
||||
|
||||
|
||||
class YandexClientIdMap(Base):
|
||||
"""Yandex Metrika client identifier captured per user.
|
||||
|
||||
Stores the mapping user_id -> yandex_cid so we can fire offline
|
||||
conversion events to mc.yandex.ru with the right CID even after
|
||||
the user leaves the landing/web flow. The ``subid`` column carries
|
||||
a pass-through traffic-source identifier for S2S postbacks.
|
||||
"""
|
||||
|
||||
__tablename__ = 'yandex_client_id_map'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), unique=True, nullable=False)
|
||||
yandex_cid = Column(String(128), nullable=False)
|
||||
source = Column(String(20), nullable=False, default='web', server_default='web')
|
||||
counter_id = Column(String(32), nullable=True)
|
||||
registration_sent = Column(Boolean, default=False, server_default=text('false'), nullable=False)
|
||||
trial_sent = Column(Boolean, default=False, server_default=text('false'), nullable=False)
|
||||
subid = Column(String(255), nullable=True)
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class InfoPage(Base):
|
||||
"""Static informational page with multilingual title/content (JSONB)."""
|
||||
|
||||
__tablename__ = 'info_pages'
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
slug = Column(String(200), unique=True, nullable=False)
|
||||
title = Column(JSONB, nullable=False, server_default='{}')
|
||||
content = Column(JSONB, nullable=False, server_default='{}')
|
||||
page_type = Column(String(20), nullable=False, default='page', server_default='page')
|
||||
is_active = Column(Boolean, nullable=False, default=True, server_default='true')
|
||||
sort_order = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
icon = Column(String(50), nullable=True)
|
||||
replaces_tab = Column(String(20), nullable=True) # 'faq', 'rules', 'privacy', 'offer', or null
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -78,6 +78,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
'SEVERPAY',
|
||||
'PAYPEAR',
|
||||
'ROLLYPAY',
|
||||
'OVERPAY',
|
||||
'AURAPAY',
|
||||
'MULENPAY',
|
||||
'PAL24',
|
||||
@@ -1269,6 +1270,9 @@ def _build_settings_keyboard(
|
||||
elif category_key == 'ROLLYPAY':
|
||||
label = texts.t('PAYMENT_ROLLYPAY', f'💳 {settings.get_rollypay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'rollypay')])
|
||||
elif category_key == 'OVERPAY':
|
||||
label = texts.t('PAYMENT_OVERPAY', f'💳 {settings.get_overpay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'overpay')])
|
||||
elif category_key == 'AURAPAY':
|
||||
label = texts.t('PAYMENT_AURAPAY', f'💳 {settings.get_aurapay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'aurapay')])
|
||||
|
||||
@@ -4518,11 +4518,9 @@ async def _grant_paid_subscription(
|
||||
trial_squads: list[str] = []
|
||||
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(db)
|
||||
if trial_uuid:
|
||||
trial_squads = [trial_uuid]
|
||||
trial_squads = await get_effective_tariff_squad_uuids(db, None)
|
||||
except Exception as error:
|
||||
logger.error('Не удалось подобрать сквад при выдаче подписки админом', admin_id=admin_id, error=error)
|
||||
|
||||
|
||||
@@ -163,6 +163,13 @@ async def route_payment_by_method(
|
||||
await process_rollypay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'overpay':
|
||||
from .overpay import process_overpay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_overpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'aurapay':
|
||||
from .aurapay import process_aurapay_payment_amount
|
||||
|
||||
@@ -491,12 +498,16 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
|
||||
amount_rubles = float(amount_text.replace(',', '.'))
|
||||
|
||||
if amount_rubles < 1:
|
||||
await message.answer('Минимальная сумма пополнения: 1 ₽', reply_markup=get_back_keyboard(db_user.language))
|
||||
await message.answer(
|
||||
'Минимальная сумма пополнения: 1 ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
return
|
||||
|
||||
if amount_rubles > 50000:
|
||||
await message.answer(
|
||||
'Максимальная сумма пополнения: 50,000 ₽', reply_markup=get_back_keyboard(db_user.language)
|
||||
'Максимальная сумма пополнения: 50,000 ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -509,7 +520,7 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
|
||||
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(
|
||||
f'❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f} ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -517,7 +528,7 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
|
||||
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(
|
||||
f'❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f} ₽'.replace(',', ' '),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -593,6 +604,7 @@ async def handle_topup_amount_callback(
|
||||
|
||||
platega_method_code = int(method[len('platega_m') :])
|
||||
await state.update_data(payment_method='platega', platega_method=platega_method_code)
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(callback.message, db_user, db, amount_kopeks, state)
|
||||
elif method == 'platega':
|
||||
@@ -604,6 +616,7 @@ async def handle_topup_amount_callback(
|
||||
method_code = int(data.get('platega_method', 0)) if data else 0
|
||||
|
||||
if method_code > 0:
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(callback.message, db_user, db, amount_kopeks, state)
|
||||
else:
|
||||
@@ -615,9 +628,12 @@ async def handle_topup_amount_callback(
|
||||
await start_tribute_payment(callback, db_user)
|
||||
return
|
||||
# Стандартные методы через роутер
|
||||
elif not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, method):
|
||||
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
|
||||
return
|
||||
else:
|
||||
await state.update_data(payment_method=method)
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
if not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, method):
|
||||
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
@@ -748,6 +764,10 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(start_rollypay_topup, F.data == 'topup_rollypay')
|
||||
|
||||
from .overpay import start_overpay_topup
|
||||
|
||||
dp.callback_query.register(start_overpay_topup, F.data == 'topup_overpay')
|
||||
|
||||
from .aurapay import start_aurapay_topup
|
||||
|
||||
dp.callback_query.register(start_aurapay_topup, F.data == 'topup_aurapay')
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Handler for Overpay balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_overpay_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating Overpay payment and sending response.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_overpay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_overpay_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'\U0001f4b3 Оплатить {amount}\u20bd',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'OVERPAY_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}\u20bd</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info('Overpay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_overpay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.OVERPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.OVERPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}\u20bd',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}\u20bd',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_overpay_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_overpay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start Overpay top-up process - ask for amount.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='overpay')
|
||||
|
||||
min_amount = settings.OVERPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.OVERPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_overpay_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'OVERPAY_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}\u20bd\n'
|
||||
'Максимум: {max_amount}\u20bd',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
@@ -42,6 +42,7 @@ async def _prompt_amount(
|
||||
# Если сумма уже известна (например, после быстрого выбора),
|
||||
# сразу создаём платеж и сбрасываем временное значение.
|
||||
await state.update_data(platega_pending_amount=None)
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
@@ -294,8 +295,9 @@ async def process_platega_payment_amount(
|
||||
'PLATEGA_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма для оплаты через Platega: {amount}',
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
|
||||
@@ -304,8 +306,9 @@ async def process_platega_payment_amount(
|
||||
'PLATEGA_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма для оплаты через Platega: {amount}',
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS)),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
return
|
||||
|
||||
try:
|
||||
|
||||
@@ -127,3 +127,12 @@ def register_handlers(dp: Dispatcher):
|
||||
F.text.is_not(None),
|
||||
~F.text.startswith('/'),
|
||||
)
|
||||
|
||||
# Ловим медиа-сообщения (фото, видео, документы, стикеры и т.д.)
|
||||
# без активного состояния — чтобы пользователь знал, что бот не принял медиа
|
||||
dp.message.register(
|
||||
handle_unknown_message,
|
||||
StateFilter(None),
|
||||
F.successful_payment.is_(None),
|
||||
F.text.is_(None),
|
||||
)
|
||||
|
||||
@@ -1148,14 +1148,18 @@ async def _show_privacy_policy_after_rules(
|
||||
logger.info('🔒 Используется политика конфиденциальности из БД для языка', language=language)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language))
|
||||
await callback.message.edit_text(
|
||||
privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language), parse_mode='HTML'
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
|
||||
logger.info('🔒 Политика конфиденциальности отправлена пользователю', from_user_id=callback.from_user.id)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error('Ошибка при показе политики конфиденциальности', error=e, exc_info=True)
|
||||
try:
|
||||
await callback.message.answer(privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language))
|
||||
await callback.message.answer(
|
||||
privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language), parse_mode='HTML'
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
|
||||
logger.info(
|
||||
'🔒 Политика конфиденциальности отправлена новым сообщением пользователю',
|
||||
@@ -1725,6 +1729,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
await callback.message.answer(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id)
|
||||
if pinned_message and not pinned_message.send_before_menu:
|
||||
@@ -2079,6 +2084,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
await message.answer(
|
||||
offer_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id)
|
||||
if pinned_message and not pinned_message.send_before_menu:
|
||||
|
||||
@@ -325,9 +325,14 @@ async def confirm_change_devices(
|
||||
if devices_difference > 0:
|
||||
additional_devices = devices_difference
|
||||
|
||||
# Для тарифов - все устройства платные (нет бесплатного лимита)
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
if tariff:
|
||||
chargeable_devices = additional_devices
|
||||
tariff_included = tariff.device_limit or 0
|
||||
if current_devices < tariff_included:
|
||||
free_devices = tariff_included - current_devices
|
||||
chargeable_devices = max(0, additional_devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = additional_devices
|
||||
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
|
||||
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
|
||||
chargeable_devices = max(0, additional_devices - free_devices)
|
||||
@@ -352,7 +357,8 @@ async def confirm_change_devices(
|
||||
)
|
||||
# Цена = месячная_цена * days_left / 30
|
||||
price = int(discounted_per_month * days_left / 30)
|
||||
price = max(100, price) # Минимум 1 рубль
|
||||
if chargeable_devices > 0:
|
||||
price = max(100, price) # Минимум 1 рубль (только для платных устройств)
|
||||
total_discount = int(discount_per_month * days_left / 30)
|
||||
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
|
||||
|
||||
@@ -553,7 +559,12 @@ async def execute_change_devices(
|
||||
devices_difference = new_devices_count - current_devices
|
||||
if devices_difference > 0:
|
||||
if tariff:
|
||||
chargeable_devices = devices_difference
|
||||
tariff_included = tariff.device_limit or 0
|
||||
if current_devices < tariff_included:
|
||||
free_devices = tariff_included - current_devices
|
||||
chargeable_devices = max(0, devices_difference - free_devices)
|
||||
else:
|
||||
chargeable_devices = devices_difference
|
||||
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
|
||||
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
|
||||
chargeable_devices = max(0, devices_difference - free_devices)
|
||||
@@ -572,7 +583,8 @@ async def execute_change_devices(
|
||||
devices_discount_percent,
|
||||
)
|
||||
price = int(discounted_per_month * days_left / 30)
|
||||
price = max(100, price)
|
||||
if chargeable_devices > 0:
|
||||
price = max(100, price)
|
||||
else:
|
||||
price = 0
|
||||
|
||||
@@ -1215,7 +1227,22 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
)
|
||||
return
|
||||
|
||||
devices_price_per_month = devices_count * price_per_device
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
current_devices = subscription.device_limit or 1
|
||||
if tariff:
|
||||
tariff_included = tariff.device_limit or 0
|
||||
if current_devices < tariff_included:
|
||||
free_devices = tariff_included - current_devices
|
||||
chargeable_devices = max(0, devices_count - free_devices)
|
||||
else:
|
||||
chargeable_devices = devices_count
|
||||
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
|
||||
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
|
||||
chargeable_devices = max(0, devices_count - free_devices)
|
||||
else:
|
||||
chargeable_devices = devices_count
|
||||
|
||||
devices_price_per_month = chargeable_devices * price_per_device
|
||||
|
||||
# TOCTOU: lock user row before reading promo/discount state
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
@@ -1240,7 +1267,8 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
)
|
||||
# Цена = месячная_цена * days_left / 30
|
||||
price = int(discounted_per_month * days_left / 30)
|
||||
price = max(100, price) # Минимум 1 рубль
|
||||
if chargeable_devices > 0:
|
||||
price = max(100, price) # Минимум 1 рубль (только для платных устройств)
|
||||
total_discount = int(discount_per_month * days_left / 30)
|
||||
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
|
||||
else:
|
||||
@@ -1260,7 +1288,8 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
)
|
||||
# Цена = месячная_цена * days_left / 30
|
||||
price = int(discounted_per_month * days_left / 30)
|
||||
price = max(100, price) # Минимум 1 рубль
|
||||
if chargeable_devices > 0:
|
||||
price = max(100, price) # Минимум 1 рубль (только для платных устройств)
|
||||
total_discount = int(discount_per_month * days_left / 30)
|
||||
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
|
||||
|
||||
|
||||
@@ -922,9 +922,11 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
|
||||
|
||||
if trial_tariff:
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
trial_device_limit = trial_tariff.device_limit
|
||||
trial_squads = trial_tariff.allowed_squads or []
|
||||
trial_squads = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
|
||||
tariff_id_for_trial = trial_tariff.id
|
||||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
@@ -937,7 +939,7 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа', error=e)
|
||||
|
||||
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
|
||||
# No trial tariff configured, use the legacy random trial squad fallback.
|
||||
if not trial_squads:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
@@ -1579,11 +1581,15 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext,
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_value = prepared_cart_data.get('traffic_gb')
|
||||
if traffic_value is None:
|
||||
traffic_value = prepared_cart_data.get('traffic_limit_gb')
|
||||
if traffic_value is None:
|
||||
traffic_value = settings.get_fixed_traffic_limit()
|
||||
traffic_display = 'Безлимитный' if traffic_value == 0 else f'{traffic_value} ГБ'
|
||||
else:
|
||||
traffic_value = prepared_cart_data.get('traffic_gb', 0) or 0
|
||||
traffic_value = prepared_cart_data.get('traffic_gb')
|
||||
if traffic_value is None:
|
||||
traffic_value = prepared_cart_data.get('traffic_limit_gb', 0)
|
||||
traffic_display = 'Безлимитный' if traffic_value == 0 else f'{traffic_value} ГБ'
|
||||
|
||||
summary_lines = [
|
||||
@@ -1596,6 +1602,8 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext,
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
devices_value = prepared_cart_data.get('devices')
|
||||
if devices_value is None:
|
||||
devices_value = prepared_cart_data.get('device_limit')
|
||||
if devices_value is not None:
|
||||
summary_lines.append(f'📱 Устройства: {devices_value}')
|
||||
|
||||
@@ -1970,7 +1978,10 @@ async def confirm_extend_subscription(
|
||||
'description': f'Продление подписки на {days} дней',
|
||||
'consume_promo_offer': bool(promo_offer_discount > 0),
|
||||
'device_limit': device_limit,
|
||||
'devices': device_limit,
|
||||
'traffic_limit_gb': renewal_traffic_gb,
|
||||
'traffic_gb': renewal_traffic_gb,
|
||||
'countries': list(subscription.connected_squads or []),
|
||||
}
|
||||
|
||||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||||
@@ -2225,7 +2236,12 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
devices_selection_enabled = settings.is_devices_selection_enabled()
|
||||
forced_disabled_limit: int | None = None
|
||||
if devices_selection_enabled:
|
||||
devices_selected = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
# Для extend-корзины ключ может быть 'device_limit' вместо 'devices'
|
||||
devices_selected = data.get('devices')
|
||||
if devices_selected is None:
|
||||
devices_selected = data.get('device_limit')
|
||||
if devices_selected is None:
|
||||
devices_selected = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_disabled_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_disabled_limit is None:
|
||||
@@ -3307,9 +3323,11 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await _get_tariff(db, trial_tariff_id)
|
||||
if trial_tariff:
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
trial_device_limit = trial_tariff.device_limit
|
||||
trial_squads = trial_tariff.allowed_squads or []
|
||||
trial_squads = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
|
||||
tariff_id_for_trial = trial_tariff.id
|
||||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
@@ -3322,7 +3340,7 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
|
||||
|
||||
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
|
||||
# No trial tariff configured, use the legacy random trial squad fallback.
|
||||
if not trial_squads:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
@@ -3673,9 +3691,11 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await _get_tariff(db, trial_tariff_id)
|
||||
if trial_tariff:
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
|
||||
trial_traffic = trial_tariff.traffic_limit_gb
|
||||
trial_devices = trial_tariff.device_limit
|
||||
trial_squads_list = trial_tariff.allowed_squads or []
|
||||
trial_squads_list = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
|
||||
tariff_id_for_trial = trial_tariff.id
|
||||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
@@ -3688,7 +3708,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
|
||||
|
||||
# Если тариф не задал серверы, получаем случайный сквад
|
||||
# Если триальный тариф не найден, используем legacy fallback со случайным сквадом.
|
||||
if not trial_squads_list:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
@@ -4506,8 +4526,11 @@ async def _extend_existing_subscription(
|
||||
'return_to_cart': True,
|
||||
'description': f'Продление подписки на {period_days} дней',
|
||||
'device_limit': device_limit,
|
||||
'devices': device_limit,
|
||||
'traffic_limit_gb': traffic_limit_gb,
|
||||
'traffic_gb': traffic_limit_gb,
|
||||
'squad_uuid': squad_uuid,
|
||||
'countries': [squad_uuid] if squad_uuid else [],
|
||||
'consume_promo_offer': consume_promo,
|
||||
}
|
||||
|
||||
|
||||
@@ -2142,6 +2142,31 @@ async def show_tariff_extend(
|
||||
await callback.answer('Тариф не найден', show_alert=True)
|
||||
return
|
||||
|
||||
# Скрытый/неактивный тариф (например, триальный после промокода) —
|
||||
# показываем список доступных тарифов вместо продления скрытого
|
||||
if not tariff.is_active:
|
||||
promo_group_id = getattr(db_user, 'promo_group_id', None)
|
||||
tariffs = await get_tariffs_for_user(db, promo_group_id)
|
||||
active_tariffs = [t for t in tariffs if not t.is_daily]
|
||||
if not active_tariffs:
|
||||
await callback.answer('Нет доступных тарифов для продления', show_alert=True)
|
||||
return
|
||||
|
||||
keyboard = []
|
||||
for t in active_tariffs:
|
||||
keyboard.append([InlineKeyboardButton(text=f'📦 {t.name}', callback_data=f'tariff_select:{t.id}')])
|
||||
keyboard.append([InlineKeyboardButton(text='◀️ Назад', callback_data='back_to_menu')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
'🔄 <b>Выберите тариф для продления</b>\n\n'
|
||||
'Для продления подписки необходимо выбрать тариф.\n'
|
||||
'Подписка будет обновлена с параметрами выбранного тарифа.',
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||||
|
||||
# Проверяем есть ли у пользователя скидки по периодам
|
||||
@@ -2641,6 +2666,18 @@ async def show_tariff_switch_list(
|
||||
|
||||
current_tariff_id = subscription.tariff_id
|
||||
|
||||
# Проверяем, разрешена ли смена тарифа хотя бы в одном направлении
|
||||
if not settings.TARIFF_SWITCH_UPGRADE_ENABLED and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
await callback.message.edit_text(
|
||||
'🚫 <b>Смена тарифа недоступна</b>\n\nАдминистратор отключил возможность смены тарифа.',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]]
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Получаем доступные тарифы
|
||||
promo_group_id = getattr(db_user, 'promo_group_id', None)
|
||||
tariffs = await get_tariffs_for_user(db, promo_group_id)
|
||||
@@ -2653,6 +2690,14 @@ async def show_tariff_switch_list(
|
||||
else:
|
||||
available_tariffs = [t for t in tariffs if t.id != current_tariff_id]
|
||||
|
||||
# Фильтруем по разрешённым направлениям (upgrade/downgrade)
|
||||
current_tariff = await get_tariff_by_id(db, current_tariff_id) if current_tariff_id else None
|
||||
if current_tariff:
|
||||
remaining_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0
|
||||
available_tariffs = _filter_tariffs_by_switch_direction(
|
||||
available_tariffs, current_tariff, remaining_days, db_user
|
||||
)
|
||||
|
||||
if not available_tariffs:
|
||||
await callback.message.edit_text(
|
||||
'😔 <b>Нет доступных тарифов для переключения</b>\n\nВы уже используете единственный доступный тариф.',
|
||||
@@ -2714,6 +2759,24 @@ async def select_tariff_switch(
|
||||
await callback.answer('Тариф недоступен', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем разрешение на смену в данном направлении
|
||||
current_subscription_sw, _sw_sub_id_check = await _resolve_subscription(callback, db_user, db, state)
|
||||
if current_subscription_sw and current_subscription_sw.tariff_id:
|
||||
cur_tariff_sw = await get_tariff_by_id(db, current_subscription_sw.tariff_id)
|
||||
if cur_tariff_sw:
|
||||
rem_days = (
|
||||
max(0, (current_subscription_sw.end_date - datetime.now(UTC)).days)
|
||||
if current_subscription_sw.end_date
|
||||
else 0
|
||||
)
|
||||
_, is_up = _calculate_instant_switch_cost(cur_tariff_sw, tariff, rem_days, db_user)
|
||||
if is_up and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
|
||||
await callback.answer('Повышение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
if not is_up and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
await callback.answer('Понижение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
|
||||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||||
|
||||
# Проверяем, суточный ли это тариф
|
||||
@@ -2935,6 +2998,19 @@ async def confirm_tariff_switch(
|
||||
await callback.answer('У вас нет активной подписки', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем разрешение на смену в данном направлении
|
||||
if subscription.tariff_id and subscription.tariff_id != tariff_id:
|
||||
cur_tariff_obj = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if cur_tariff_obj:
|
||||
rem_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0
|
||||
_, is_up = _calculate_instant_switch_cost(cur_tariff_obj, tariff, rem_days, db_user)
|
||||
if is_up and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
|
||||
await callback.answer('Повышение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
if not is_up and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
await callback.answer('Понижение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
|
||||
# Calculate price via PricingEngine (handles per-category discounts + extra devices)
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
@@ -3187,6 +3263,19 @@ async def confirm_daily_tariff_switch(
|
||||
await callback.answer('У вас нет активной подписки', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем разрешение на смену в данном направлении
|
||||
if subscription.tariff_id and subscription.tariff_id != tariff_id:
|
||||
cur_tariff_daily = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if cur_tariff_daily:
|
||||
rem_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0
|
||||
_, is_up = _calculate_instant_switch_cost(cur_tariff_daily, tariff, rem_days, db_user)
|
||||
if is_up and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
|
||||
await callback.answer('Повышение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
if not is_up and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
await callback.answer('Понижение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
@@ -3418,6 +3507,30 @@ def _calculate_instant_switch_cost(
|
||||
return result.upgrade_cost, result.is_upgrade
|
||||
|
||||
|
||||
def _filter_tariffs_by_switch_direction(
|
||||
tariffs: list[Tariff],
|
||||
current_tariff: Tariff,
|
||||
remaining_days: int,
|
||||
db_user: User | None = None,
|
||||
) -> list[Tariff]:
|
||||
"""Фильтрует тарифы по разрешённым направлениям смены (upgrade/downgrade)."""
|
||||
upgrade_ok = settings.TARIFF_SWITCH_UPGRADE_ENABLED
|
||||
downgrade_ok = settings.TARIFF_SWITCH_DOWNGRADE_ENABLED
|
||||
|
||||
if upgrade_ok and downgrade_ok:
|
||||
return tariffs
|
||||
|
||||
filtered = []
|
||||
for tariff in tariffs:
|
||||
if tariff.id == current_tariff.id:
|
||||
filtered.append(tariff)
|
||||
continue
|
||||
_, is_upgrade = _calculate_instant_switch_cost(current_tariff, tariff, remaining_days, db_user)
|
||||
if (is_upgrade and upgrade_ok) or (not is_upgrade and downgrade_ok):
|
||||
filtered.append(tariff)
|
||||
return filtered
|
||||
|
||||
|
||||
def format_instant_switch_list_text(
|
||||
tariffs: list[Tariff],
|
||||
current_tariff: Tariff,
|
||||
@@ -3425,16 +3538,21 @@ def format_instant_switch_list_text(
|
||||
db_user: User | None = None,
|
||||
) -> str:
|
||||
"""Форматирует текст со списком тарифов для мгновенного переключения."""
|
||||
upgrade_ok = settings.TARIFF_SWITCH_UPGRADE_ENABLED
|
||||
downgrade_ok = settings.TARIFF_SWITCH_DOWNGRADE_ENABLED
|
||||
|
||||
lines = [
|
||||
'📦 <b>Мгновенная смена тарифа</b>',
|
||||
f'📌 Текущий: <b>{html.escape(current_tariff.name)}</b>',
|
||||
f'⏰ Осталось: <b>{remaining_days} дн.</b>',
|
||||
'',
|
||||
'💡 При переключении остаток дней сохраняется.',
|
||||
'⬆️ Повышение тарифа = доплата за разницу',
|
||||
'⬇️ Понижение = бесплатно',
|
||||
'',
|
||||
]
|
||||
if upgrade_ok:
|
||||
lines.append('⬆️ Повышение тарифа = доплата за разницу')
|
||||
if downgrade_ok:
|
||||
lines.append('⬇️ Понижение = бесплатно')
|
||||
lines.append('')
|
||||
|
||||
for tariff in tariffs:
|
||||
if tariff.id == current_tariff.id:
|
||||
@@ -3566,6 +3684,18 @@ async def show_instant_switch_list(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Проверяем, разрешена ли смена тарифа хотя бы в одном направлении
|
||||
if not settings.TARIFF_SWITCH_UPGRADE_ENABLED and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
await callback.message.edit_text(
|
||||
'🚫 <b>Смена тарифа недоступна</b>\n\nАдминистратор отключил возможность смены тарифа.',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]]
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Получаем доступные тарифы
|
||||
promo_group_id = getattr(db_user, 'promo_group_id', None)
|
||||
tariffs = await get_tariffs_for_user(db, promo_group_id)
|
||||
@@ -3578,6 +3708,9 @@ async def show_instant_switch_list(
|
||||
else:
|
||||
available_tariffs = [t for t in tariffs if t.id != current_tariff.id]
|
||||
|
||||
# Фильтруем по разрешённым направлениям (upgrade/downgrade)
|
||||
available_tariffs = _filter_tariffs_by_switch_direction(available_tariffs, current_tariff, remaining_days, db_user)
|
||||
|
||||
if not available_tariffs:
|
||||
await callback.message.edit_text(
|
||||
'😔 <b>Нет доступных тарифов для переключения</b>\n\nВы уже используете единственный доступный тариф.',
|
||||
@@ -3647,6 +3780,14 @@ async def preview_instant_switch(
|
||||
# Рассчитываем стоимость переключения
|
||||
upgrade_cost, is_upgrade = _calculate_instant_switch_cost(current_tariff, new_tariff, remaining_days, db_user)
|
||||
|
||||
# Проверяем разрешение на смену в данном направлении
|
||||
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
|
||||
await callback.answer('Повышение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
await callback.answer('Понижение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем баланс
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
|
||||
@@ -3821,6 +3962,14 @@ async def confirm_instant_switch(
|
||||
is_upgrade = switch_result.is_upgrade
|
||||
consume_promo = switch_result.offer_discount_pct > 0
|
||||
|
||||
# Проверяем разрешение на смену в данном направлении
|
||||
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
|
||||
await callback.answer('Повышение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
await callback.answer('Понижение тарифа недоступно', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем баланс если это upgrade (use locked user's fresh balance)
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
if is_upgrade and user_balance < upgrade_cost:
|
||||
|
||||
+14
-2
@@ -1831,6 +1831,18 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_overpay_enabled():
|
||||
overpay_name = settings.get_overpay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_OVERPAY', f'💳 {overpay_name}'),
|
||||
callback_data=_build_callback('overpay'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_aurapay_enabled():
|
||||
aurapay_name = settings.get_aurapay_display_name()
|
||||
keyboard.append(
|
||||
@@ -2310,8 +2322,8 @@ def get_change_devices_keyboard(
|
||||
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
|
||||
if tariff and tariff_device_price:
|
||||
device_price_per_month = tariff_device_price
|
||||
# Для тарифов все устройства платные (нет бесплатного лимита)
|
||||
default_device_limit = 0
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
default_device_limit = tariff.device_limit if tariff else 0
|
||||
else:
|
||||
device_price_per_month = settings.PRICE_PER_DEVICE
|
||||
default_device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
@@ -15,6 +15,7 @@ Usage::
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -56,6 +57,46 @@ def _prefix_logger_name(logger: Any, method_name: str, event_dict: dict[str, Any
|
||||
return event_dict
|
||||
|
||||
|
||||
def _auto_capture_exc_info(logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Auto-populate event_dict['exc_info'] so tracebacks render in files/console.
|
||||
|
||||
Without this, callers must pass ``exc_info=True`` at every ``logger.error``
|
||||
site. Instead, we try:
|
||||
1. exc_info=True → replace with sys.exc_info() (standard structlog behaviour)
|
||||
2. no exc_info but we're inside an active except block → use sys.exc_info()
|
||||
3. error/exc/exception/e/err kwarg is a BaseException with __traceback__ →
|
||||
synthesize an exc_info tuple from it
|
||||
|
||||
Result: ``logger.error('msg', error=e)`` inside any ``except`` block now
|
||||
renders the full traceback to files, console, and Telegram automatically.
|
||||
"""
|
||||
exc_info = event_dict.get('exc_info')
|
||||
if exc_info is True:
|
||||
current = sys.exc_info()
|
||||
if current[1] is not None:
|
||||
event_dict['exc_info'] = current
|
||||
return event_dict
|
||||
|
||||
if exc_info:
|
||||
return event_dict
|
||||
|
||||
# Only auto-capture from sys.exc_info() for error/critical levels.
|
||||
# For warning/info inside except blocks, callers must pass exc_info=True explicitly.
|
||||
if method_name in ('error', 'critical', 'exception'):
|
||||
current = sys.exc_info()
|
||||
if current[1] is not None:
|
||||
event_dict['exc_info'] = current
|
||||
return event_dict
|
||||
|
||||
for key in ('error', 'exc', 'exception', 'e', 'err'):
|
||||
candidate = event_dict.get(key)
|
||||
if isinstance(candidate, BaseException) and candidate.__traceback__ is not None:
|
||||
event_dict['exc_info'] = (type(candidate), candidate, candidate.__traceback__)
|
||||
return event_dict
|
||||
|
||||
return event_dict
|
||||
|
||||
|
||||
def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]:
|
||||
"""Configure structlog and return formatters + notifier.
|
||||
|
||||
@@ -82,6 +123,11 @@ def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]:
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
timestamper,
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
# Auto-capture traceback from sys.exc_info()/error-kwarg BEFORE any
|
||||
# consumer looks at event_dict. Runs for ALL log levels so files,
|
||||
# console, and Telegram all see the same traceback without requiring
|
||||
# every caller to pass exc_info=True.
|
||||
_auto_capture_exc_info,
|
||||
# TelegramNotifierProcessor MUST run while exc_info is still a raw
|
||||
# tuple so it can extract the traceback for Telegram notifications.
|
||||
# ConsoleRenderer handles exc_info formatting downstream (with Rich
|
||||
|
||||
+16
-1
@@ -129,13 +129,28 @@ class TelegramNotifierProcessor:
|
||||
if any(logger_name.startswith(prefix) for prefix in IGNORED_LOGGER_PREFIXES):
|
||||
return event_dict
|
||||
|
||||
# 4. Resolve exc_info=True to actual tuple while still in except block.
|
||||
# 4. Resolve exc_info into actual tuple while still in except block.
|
||||
# logger.exception() sets exc_info=True (bool); we need the tuple for
|
||||
# traceback extraction. sys.exc_info() works because the processor runs
|
||||
# synchronously inside the except clause.
|
||||
#
|
||||
# If exc_info is not passed at all, auto-capture traceback from:
|
||||
# (a) sys.exc_info() — works when logger.error is called inside except
|
||||
# (b) error/exc/exception kwargs if they carry __traceback__
|
||||
# This avoids having to pass exc_info=True at every logger.error site.
|
||||
exc_info = event_dict.get('exc_info')
|
||||
if exc_info is True:
|
||||
event_dict['exc_info'] = sys.exc_info()
|
||||
elif not exc_info:
|
||||
current = sys.exc_info()
|
||||
if current[1] is not None:
|
||||
event_dict['exc_info'] = current
|
||||
else:
|
||||
for key in ('error', 'exc', 'exception', 'e', 'err'):
|
||||
candidate = event_dict.get(key)
|
||||
if isinstance(candidate, BaseException) and candidate.__traceback__ is not None:
|
||||
event_dict['exc_info'] = (type(candidate), candidate, candidate.__traceback__)
|
||||
break
|
||||
|
||||
# 5. Bot not initialized yet — skip
|
||||
bot = self._bot
|
||||
|
||||
@@ -159,17 +159,13 @@ class AdvertisingCampaignService:
|
||||
device_limit = campaign.subscription_device_limit
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
try:
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
|
||||
if not squads:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(db)
|
||||
if trial_uuid:
|
||||
squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
logger.error('Не удалось подобрать сквад для кампании', campaign_id=campaign.id, error=error)
|
||||
squads = await get_effective_tariff_squad_uuids(db, campaign.subscription_squads)
|
||||
except Exception as error:
|
||||
logger.error('Не удалось подобрать сквады для кампании', campaign_id=campaign.id, error=error)
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
|
||||
if existing_subscription:
|
||||
# Multi-tariff: extend the best existing subscription
|
||||
@@ -305,17 +301,13 @@ class AdvertisingCampaignService:
|
||||
|
||||
traffic_limit = tariff.traffic_limit_gb
|
||||
device_limit = tariff.device_limit
|
||||
squads = list(tariff.allowed_squads or [])
|
||||
try:
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
|
||||
if not squads:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(db)
|
||||
if trial_uuid:
|
||||
squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
logger.error('Не удалось подобрать сквад для тарифа кампании', campaign_id=campaign.id, error=error)
|
||||
squads = await get_effective_tariff_squad_uuids(db, tariff.allowed_squads)
|
||||
except Exception as error:
|
||||
logger.error('Не удалось подобрать сквады для тарифа кампании', campaign_id=campaign.id, error=error)
|
||||
squads = list(tariff.allowed_squads or [])
|
||||
|
||||
if existing_subscription:
|
||||
# Multi-tariff: extend the existing subscription for this tariff
|
||||
|
||||
@@ -145,6 +145,8 @@ async def create_purchase(
|
||||
gift_recipient_value: str | None = None,
|
||||
gift_message: str | None = None,
|
||||
source: str = 'landing',
|
||||
subid: str | None = None,
|
||||
referrer: str | None = None,
|
||||
buyer_user_id: int | None = None,
|
||||
commit: bool = True,
|
||||
) -> GuestPurchase:
|
||||
@@ -152,6 +154,8 @@ async def create_purchase(
|
||||
purchase = await create_guest_purchase(
|
||||
db,
|
||||
commit=commit,
|
||||
subid=subid,
|
||||
referrer=referrer,
|
||||
landing_id=landing.id if landing else None,
|
||||
tariff_id=tariff.id,
|
||||
period_days=period_days,
|
||||
@@ -438,28 +442,93 @@ async def fulfill_purchase(
|
||||
purchase.status = GuestPurchaseStatus.DELIVERED.value
|
||||
purchase.user_id = user.id
|
||||
purchase.delivered_at = datetime.now(UTC)
|
||||
|
||||
# Extract subid from Redis cache (saved at purchase creation)
|
||||
try:
|
||||
from app.utils.cache import cache
|
||||
|
||||
_cached_subid = await cache.get(f'subid:purchase:{purchase.token}')
|
||||
if _cached_subid:
|
||||
purchase.subid = _cached_subid if isinstance(_cached_subid, str) else _cached_subid.decode()
|
||||
from app.database.crud.yandex_client_id import upsert_subid
|
||||
|
||||
await upsert_subid(db, user.id, purchase.subid, source='landing')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if recipient_type == 'email' and not purchase.is_gift and is_new_account:
|
||||
purchase.auto_login_token = create_auto_login_token(user.id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user', 'buyer'])
|
||||
|
||||
# Create transaction so promo group auto-assignment and contest tracking work
|
||||
# Create transaction so promo group auto-assignment and contest tracking work.
|
||||
# Skip for gift recipients — they didn't pay, so their spending shouldn't be inflated.
|
||||
transaction = None
|
||||
if not purchase.is_gift:
|
||||
try:
|
||||
payment_method_enum = _resolve_payment_method(purchase.payment_method)
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=purchase.amount_kopeks,
|
||||
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
|
||||
payment_method=payment_method_enum,
|
||||
external_id=purchase.payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create transaction for guest purchase', purchase_id=purchase.id)
|
||||
|
||||
# Save Yandex CID from Redis → DB (enables on_registration/on_purchase to use it)
|
||||
try:
|
||||
payment_method_enum = _resolve_payment_method(purchase.payment_method)
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=purchase.amount_kopeks,
|
||||
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
|
||||
payment_method=payment_method_enum,
|
||||
external_id=purchase.payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
from app.services import yandex_offline_conv_service as yandex_conv
|
||||
from app.utils.cache import cache
|
||||
|
||||
_cached_cid = await cache.get(f'yacid:purchase:{purchase.token}')
|
||||
if _cached_cid:
|
||||
await yandex_conv.store_cid(db, user.id, _cached_cid, source='landing')
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.exception('Failed to create transaction for guest purchase', purchase_id=purchase.id)
|
||||
logger.debug('Failed to save CID from Redis')
|
||||
|
||||
# Registration event (new accounts only) + S2S postback
|
||||
if is_new_account:
|
||||
try:
|
||||
from app.services import yandex_offline_conv_service as yandex_conv
|
||||
|
||||
await yandex_conv.on_registration(db, user.id)
|
||||
except Exception:
|
||||
logger.debug('Yandex on_registration hook error')
|
||||
|
||||
try:
|
||||
from app.database.crud.yandex_client_id import get_subid
|
||||
from app.services.s2s_postback_service import send_postback
|
||||
|
||||
_subid = purchase.subid or await get_subid(db, user.id)
|
||||
if _subid:
|
||||
await send_postback('registration', _subid, user_id=user.id)
|
||||
except Exception:
|
||||
logger.debug('S2S postback registration hook error')
|
||||
|
||||
# Purchase event + S2S postback (always for paid purchases)
|
||||
try:
|
||||
from app.services import yandex_offline_conv_service as yandex_conv
|
||||
|
||||
await yandex_conv.on_purchase(db, user.id, purchase.amount_kopeks)
|
||||
except Exception:
|
||||
logger.debug('Yandex on_purchase hook error')
|
||||
|
||||
try:
|
||||
from app.database.crud.yandex_client_id import get_subid
|
||||
from app.services.s2s_postback_service import send_postback
|
||||
|
||||
_subid = purchase.subid or await get_subid(db, user.id)
|
||||
if _subid:
|
||||
await send_postback('purchase', _subid, amount=purchase.amount_kopeks / 100, user_id=user.id)
|
||||
except Exception:
|
||||
logger.debug('S2S postback purchase hook error')
|
||||
|
||||
try:
|
||||
await send_guest_notification(
|
||||
@@ -1147,21 +1216,23 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
||||
await db.commit()
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user', 'buyer'])
|
||||
|
||||
# Create transaction so promo group auto-assignment and contest tracking work
|
||||
try:
|
||||
payment_method_enum = _resolve_payment_method(purchase.payment_method)
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=purchase.amount_kopeks,
|
||||
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
|
||||
payment_method=payment_method_enum,
|
||||
external_id=purchase.payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create transaction for activated purchase', purchase_id=purchase.id)
|
||||
# Create transaction so promo group auto-assignment and contest tracking work.
|
||||
# Skip for gift recipients — they didn't pay, so their spending shouldn't be inflated.
|
||||
if not purchase.is_gift:
|
||||
try:
|
||||
payment_method_enum = _resolve_payment_method(purchase.payment_method)
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=purchase.amount_kopeks,
|
||||
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
|
||||
payment_method=payment_method_enum,
|
||||
external_id=purchase.payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create transaction for activated purchase', purchase_id=purchase.id)
|
||||
|
||||
if not skip_notification:
|
||||
try:
|
||||
|
||||
+230
-156
@@ -1202,160 +1202,229 @@ class MonitoringService:
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for subscription in autopay_subscriptions:
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск автоплатежа подписки : обновлена вебхуком недавно', subscription_id=subscription.id
|
||||
)
|
||||
continue
|
||||
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
user_identifier = user.telegram_id or f'email:{user.id}'
|
||||
|
||||
# Определяем период продления: из тарифа (минимальный) или 30 дней по умолчанию
|
||||
tariff = getattr(subscription, 'tariff', None)
|
||||
if tariff:
|
||||
autopay_period = tariff.get_shortest_period() or 30
|
||||
else:
|
||||
autopay_period = 30
|
||||
# Захватываем (sub_id, user_id) ДО цикла, пока сессия ещё свежая.
|
||||
# В цикле каждую итерацию делаем refetch subscription+user через
|
||||
# async-запрос: это единственный безопасный способ избежать
|
||||
# MissingGreenlet при sync-lazy-load, который SQLAlchemy 2.0 async
|
||||
# session не поддерживает (напр. lock_user_for_pricing c
|
||||
# populate_existing=True разгружает Subscription.user backref).
|
||||
autopay_pairs: list[tuple[int, int]] = [(s.id, s.user_id) for s in autopay_subscriptions]
|
||||
|
||||
for sub_id_local, sub_user_id_local in autopay_pairs:
|
||||
try:
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
subscription,
|
||||
autopay_period,
|
||||
user=user,
|
||||
# Refetch subscription с eager load user/tariff —
|
||||
# никаких lazy access по ходу итерации.
|
||||
refetch_result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user).options(
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(Subscription.id == sub_id_local)
|
||||
)
|
||||
renewal_cost = pricing.final_total
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка расчёта стоимости автопродления, пропускаем',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
error=str(e),
|
||||
)
|
||||
failed_count += 1
|
||||
continue
|
||||
subscription = refetch_result.scalar_one_or_none()
|
||||
if subscription is None:
|
||||
continue
|
||||
|
||||
if renewal_cost <= 0:
|
||||
logger.warning(
|
||||
'Нулевая стоимость автопродления, пропускаем',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
renewal_cost=renewal_cost,
|
||||
)
|
||||
failed_count += 1
|
||||
continue
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
# calculate_renewal_price уже включает promo_group + promo_offer скидки.
|
||||
# Не применяем promo_offer повторно — только consume-им при успешной оплате.
|
||||
charge_amount = renewal_cost
|
||||
promo_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск автоплатежа подписки : обновлена вебхуком недавно',
|
||||
subscription_id=subscription.id,
|
||||
)
|
||||
continue
|
||||
|
||||
autopay_key = f'autopay_{user.id}_{subscription.id}'
|
||||
if autopay_key in self._notified_users:
|
||||
continue
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
if user.balance_kopeks >= charge_amount:
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
charge_amount,
|
||||
'Автопродление подписки',
|
||||
consume_promo_offer=promo_discount_percent > 0,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
user_identifier = user.telegram_id or f'email:{user.id}'
|
||||
|
||||
if success:
|
||||
# extend_subscription сам обработает EXPIRED→ACTIVE переход
|
||||
# (проверяет status + end_date для определения was_expired)
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value:
|
||||
logger.info(
|
||||
'🔄 Autopay: продление EXPIRED подписки (восстановление)',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
old_end_date = subscription.end_date
|
||||
await extend_subscription(db, subscription, autopay_period)
|
||||
await self.subscription_service.update_remnawave_user(
|
||||
# Определяем период продления: из тарифа (минимальный) или 30 дней по умолчанию
|
||||
tariff = getattr(subscription, 'tariff', None)
|
||||
if tariff:
|
||||
autopay_period = tariff.get_shortest_period() or 30
|
||||
else:
|
||||
autopay_period = 30
|
||||
|
||||
try:
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason='автопродление подписки',
|
||||
autopay_period,
|
||||
user=user,
|
||||
)
|
||||
renewal_cost = pricing.final_total
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка расчёта стоимости автопродления, пропускаем',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
error=str(e),
|
||||
)
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
if renewal_cost <= 0:
|
||||
logger.warning(
|
||||
'Нулевая стоимость автопродления, пропускаем',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
renewal_cost=renewal_cost,
|
||||
)
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# calculate_renewal_price уже включает promo_group + promo_offer скидки.
|
||||
# Не применяем promo_offer повторно — только consume-им при успешной оплате.
|
||||
charge_amount = renewal_cost
|
||||
promo_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
|
||||
autopay_key = f'autopay_{user.id}_{subscription.id}'
|
||||
if autopay_key in self._notified_users:
|
||||
continue
|
||||
|
||||
if user.balance_kopeks >= charge_amount:
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
charge_amount,
|
||||
'Автопродление подписки',
|
||||
consume_promo_offer=promo_discount_percent > 0,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
|
||||
# Создаём транзакцию, чтобы автопродление было видно в статистике и карточке пользователя
|
||||
try:
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=charge_amount,
|
||||
description=f'Автопродление подписки на {autopay_period} дней',
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Не удалось создать транзакцию автопродления', user_id=user.id, exc=exc)
|
||||
transaction = None
|
||||
|
||||
# Отправляем уведомление администраторам
|
||||
try:
|
||||
from app.services.subscription_renewal_service import with_admin_notification_service
|
||||
|
||||
if transaction:
|
||||
await with_admin_notification_service(
|
||||
lambda svc: svc.send_subscription_extension_notification(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
transaction,
|
||||
autopay_period,
|
||||
old_end_date,
|
||||
new_end_date=subscription.end_date,
|
||||
balance_after=user.balance_kopeks,
|
||||
)
|
||||
if success:
|
||||
# subtract_user_balance мог оставить сессию в expired state
|
||||
# (напр. rollback внутри log_promo_offer_action при consume_promo_offer).
|
||||
# Перезагружаем subscription с eager-загрузкой user/tariff, чтобы
|
||||
# избежать MissingGreenlet на последующих обращениях к subscription.*
|
||||
refetch_result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
except Exception as exc:
|
||||
.where(Subscription.id == subscription.id)
|
||||
)
|
||||
refreshed_subscription = refetch_result.scalar_one_or_none()
|
||||
if refreshed_subscription is None:
|
||||
logger.warning(
|
||||
'Подписка пропала после списания — пропускаем шаги продления',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
processed_count += 1
|
||||
self._notified_users.add(autopay_key)
|
||||
continue
|
||||
subscription = refreshed_subscription
|
||||
|
||||
# extend_subscription сам обработает EXPIRED→ACTIVE переход
|
||||
# (проверяет status + end_date для определения was_expired)
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value:
|
||||
logger.info(
|
||||
'🔄 Autopay: продление EXPIRED подписки (восстановление)',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
old_end_date = subscription.end_date
|
||||
await extend_subscription(db, subscription, autopay_period)
|
||||
await self.subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason='автопродление подписки',
|
||||
)
|
||||
|
||||
# Создаём транзакцию, чтобы автопродление было видно в статистике и карточке пользователя
|
||||
try:
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=charge_amount,
|
||||
description=f'Автопродление подписки на {autopay_period} дней',
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Не удалось создать транзакцию автопродления', user_id=user.id, exc=exc)
|
||||
transaction = None
|
||||
|
||||
# Отправляем уведомление администраторам
|
||||
try:
|
||||
from app.services.subscription_renewal_service import with_admin_notification_service
|
||||
|
||||
if transaction:
|
||||
await with_admin_notification_service(
|
||||
lambda svc: svc.send_subscription_extension_notification(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
transaction,
|
||||
autopay_period,
|
||||
old_end_date,
|
||||
new_end_date=subscription.end_date,
|
||||
balance_after=user.balance_kopeks,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
'Не удалось отправить админ-уведомление об автопродлении', user_id=user.id, exc=exc
|
||||
)
|
||||
|
||||
# Send notification via appropriate channel
|
||||
if user.telegram_id and self.bot:
|
||||
await self._send_autopay_success_notification(
|
||||
user, charge_amount, autopay_period, subscription=subscription
|
||||
)
|
||||
elif not user.telegram_id:
|
||||
# Email-only user - use notification delivery service
|
||||
await notification_delivery_service.notify_autopay_success(
|
||||
user=user,
|
||||
amount_kopeks=charge_amount,
|
||||
new_expires_at=subscription.end_date,
|
||||
)
|
||||
|
||||
processed_count += 1
|
||||
self._notified_users.add(autopay_key)
|
||||
logger.info(
|
||||
'💳 Автопродление подписки пользователя успешно (списано , скидка %)',
|
||||
user_identifier=user_identifier,
|
||||
charge_amount=charge_amount,
|
||||
promo_discount_percent=promo_discount_percent,
|
||||
)
|
||||
else:
|
||||
failed_count += 1
|
||||
if await self._check_autopay_fail_cooldown(user.id, user_identifier):
|
||||
if user.telegram_id and self.bot:
|
||||
await self._send_autopay_failed_notification(
|
||||
user, user.balance_kopeks, charge_amount, subscription=subscription
|
||||
)
|
||||
elif not user.telegram_id:
|
||||
await notification_delivery_service.notify_autopay_failed(
|
||||
user=user,
|
||||
reason='Ошибка списания средств',
|
||||
)
|
||||
await self._set_autopay_fail_cooldown(user.id, user_identifier)
|
||||
logger.warning(
|
||||
'Не удалось отправить админ-уведомление об автопродлении', user_id=user.id, exc=exc
|
||||
'💳 Ошибка списания средств для автопродления пользователя',
|
||||
user_identifier=user_identifier,
|
||||
)
|
||||
|
||||
# Send notification via appropriate channel
|
||||
if user.telegram_id and self.bot:
|
||||
await self._send_autopay_success_notification(
|
||||
user, charge_amount, autopay_period, subscription=subscription
|
||||
)
|
||||
elif not user.telegram_id:
|
||||
# Email-only user - use notification delivery service
|
||||
await notification_delivery_service.notify_autopay_success(
|
||||
user=user,
|
||||
amount_kopeks=charge_amount,
|
||||
new_expires_at=subscription.end_date,
|
||||
)
|
||||
|
||||
processed_count += 1
|
||||
self._notified_users.add(autopay_key)
|
||||
logger.info(
|
||||
'💳 Автопродление подписки пользователя успешно (списано , скидка %)',
|
||||
user_identifier=user_identifier,
|
||||
charge_amount=charge_amount,
|
||||
promo_discount_percent=promo_discount_percent,
|
||||
)
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
if await self._check_autopay_fail_cooldown(user.id, user_identifier):
|
||||
if user.telegram_id and self.bot:
|
||||
await self._send_autopay_failed_notification(
|
||||
@@ -1364,30 +1433,35 @@ class MonitoringService:
|
||||
elif not user.telegram_id:
|
||||
await notification_delivery_service.notify_autopay_failed(
|
||||
user=user,
|
||||
reason='Ошибка списания средств',
|
||||
reason='Недостаточно средств на балансе',
|
||||
)
|
||||
await self._set_autopay_fail_cooldown(user.id, user_identifier)
|
||||
|
||||
logger.warning(
|
||||
'💳 Ошибка списания средств для автопродления пользователя', user_identifier=user_identifier
|
||||
'💳 Недостаточно средств для автопродления у пользователя',
|
||||
user_identifier=user_identifier,
|
||||
)
|
||||
else:
|
||||
except Exception as sub_error:
|
||||
failed_count += 1
|
||||
|
||||
if await self._check_autopay_fail_cooldown(user.id, user_identifier):
|
||||
if user.telegram_id and self.bot:
|
||||
await self._send_autopay_failed_notification(
|
||||
user, user.balance_kopeks, charge_amount, subscription=subscription
|
||||
)
|
||||
elif not user.telegram_id:
|
||||
await notification_delivery_service.notify_autopay_failed(
|
||||
user=user,
|
||||
reason='Недостаточно средств на балансе',
|
||||
)
|
||||
await self._set_autopay_fail_cooldown(user.id, user_identifier)
|
||||
|
||||
logger.warning(
|
||||
'💳 Недостаточно средств для автопродления у пользователя', user_identifier=user_identifier
|
||||
# Используем локально захваченные id — subscription-объект
|
||||
# может быть expired после чужого rollback'а.
|
||||
logger.error(
|
||||
'Ошибка автопродления отдельной подписки',
|
||||
subscription_id=sub_id_local,
|
||||
user_id=sub_user_id_local,
|
||||
error=sub_error,
|
||||
exc_info=True,
|
||||
)
|
||||
# Сессия могла остаться с aborted-транзакцией — откатываем,
|
||||
# чтобы следующая итерация начала refetch на чистой сессии.
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error:
|
||||
logger.warning(
|
||||
'Не удалось сделать rollback сессии после ошибки автопродления',
|
||||
rollback_error=rollback_error,
|
||||
)
|
||||
continue
|
||||
|
||||
if processed_count > 0 or failed_count > 0:
|
||||
await self._log_monitoring_event(
|
||||
@@ -1398,7 +1472,7 @@ class MonitoringService:
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обработки автоплатежей', error=e)
|
||||
logger.error('Ошибка обработки автоплатежей', error=e, exc_info=True)
|
||||
|
||||
async def _send_subscription_expired_notification(
|
||||
self, user: User, subscription: Subscription, *, tariff_name: str | None = None
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Сервис для работы с API Overpay (pay.overpay.io)."""
|
||||
|
||||
import ssl
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
BestAvailableEncryption,
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
pkcs12,
|
||||
)
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class OverpayAPIError(Exception):
|
||||
"""Ошибка API Overpay."""
|
||||
|
||||
def __init__(self, status_code: int, message: str):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
super().__init__(f'Overpay API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class OverpayService:
|
||||
"""Сервис для работы с API Overpay.
|
||||
|
||||
Overpay использует HTTP Basic Auth + mTLS (P12 сертификат).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._ssl_context: ssl.SSLContext | None = None
|
||||
self._temp_cert_file: str | None = None
|
||||
self._temp_key_file: str | None = None
|
||||
|
||||
@property
|
||||
def api_url(self) -> str:
|
||||
return (settings.OVERPAY_API_URL or 'https://api.overpay.io').rstrip('/')
|
||||
|
||||
@property
|
||||
def username(self) -> str:
|
||||
return settings.OVERPAY_USERNAME or ''
|
||||
|
||||
@property
|
||||
def password(self) -> str:
|
||||
return settings.OVERPAY_PASSWORD or ''
|
||||
|
||||
@property
|
||||
def project_id(self) -> str:
|
||||
return settings.OVERPAY_PROJECT_ID or ''
|
||||
|
||||
def _build_ssl_context(self) -> ssl.SSLContext | None:
|
||||
"""Создает SSL контекст с P12 сертификатом для mTLS."""
|
||||
p12_path = settings.OVERPAY_P12_PATH
|
||||
if not p12_path:
|
||||
return None
|
||||
|
||||
if self._ssl_context is not None:
|
||||
return self._ssl_context
|
||||
|
||||
try:
|
||||
passphrase = settings.OVERPAY_P12_PASSPHRASE
|
||||
passphrase_bytes = passphrase.encode('utf-8') if passphrase else None
|
||||
|
||||
with open(p12_path, 'rb') as f:
|
||||
p12_data = f.read()
|
||||
|
||||
private_key, certificate, additional_certs = pkcs12.load_key_and_certificates(p12_data, passphrase_bytes)
|
||||
|
||||
# Write PEM files to temp files for ssl.SSLContext
|
||||
cert_pem = certificate.public_bytes(Encoding.PEM)
|
||||
if additional_certs:
|
||||
for cert in additional_certs:
|
||||
cert_pem += cert.public_bytes(Encoding.PEM)
|
||||
|
||||
if passphrase_bytes:
|
||||
key_pem = private_key.private_bytes(
|
||||
Encoding.PEM,
|
||||
PrivateFormat.TraditionalOpenSSL,
|
||||
BestAvailableEncryption(passphrase_bytes),
|
||||
)
|
||||
else:
|
||||
key_pem = private_key.private_bytes(
|
||||
Encoding.PEM,
|
||||
PrivateFormat.TraditionalOpenSSL,
|
||||
NoEncryption(),
|
||||
)
|
||||
|
||||
# Write to temp files
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.pem') as cert_file:
|
||||
cert_file.write(cert_pem)
|
||||
cert_file.flush()
|
||||
self._temp_cert_file = cert_file.name
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.pem') as key_file:
|
||||
key_file.write(key_pem)
|
||||
key_file.flush()
|
||||
self._temp_key_file = key_file.name
|
||||
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.load_cert_chain(
|
||||
certfile=self._temp_cert_file,
|
||||
keyfile=self._temp_key_file,
|
||||
password=passphrase,
|
||||
)
|
||||
ctx.load_default_certs()
|
||||
self._ssl_context = ctx
|
||||
logger.info('Overpay: SSL контекст с P12 сертификатом создан')
|
||||
return ctx
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Overpay: ошибка загрузки P12 сертификата', error=e)
|
||||
return None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Возвращает переиспользуемый HTTP-клиент с mTLS."""
|
||||
if self._client is not None and not self._client.is_closed:
|
||||
return self._client
|
||||
|
||||
ssl_context = self._build_ssl_context()
|
||||
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(30.0),
|
||||
auth=httpx.BasicAuth(self.username, self.password),
|
||||
verify=ssl_context if ssl_context else True,
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрывает HTTP-клиент."""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
# Clean up temp files
|
||||
from pathlib import Path
|
||||
|
||||
for path in (self._temp_cert_file, self._temp_key_file):
|
||||
if path:
|
||||
try:
|
||||
Path(path).unlink()
|
||||
except OSError:
|
||||
pass
|
||||
self._temp_cert_file = None
|
||||
self._temp_key_file = None
|
||||
self._ssl_context = None
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
amount: str,
|
||||
currency: str = 'RUB',
|
||||
lifetime_minutes: int = 1440,
|
||||
merchant_transaction_id: str,
|
||||
description: str = '',
|
||||
return_url: str | None = None,
|
||||
payment_methods: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Создает платеж через API Overpay.
|
||||
POST {API_URL}/orders/
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'amount': amount,
|
||||
'currency': currency,
|
||||
'livetimeMinutes': lifetime_minutes,
|
||||
'projectId': self.project_id,
|
||||
'merchantTransactionId': merchant_transaction_id,
|
||||
}
|
||||
|
||||
if description:
|
||||
payload['description'] = description
|
||||
if return_url:
|
||||
payload['returnUrl'] = return_url
|
||||
if payment_methods:
|
||||
payload['paymentMethods'] = payment_methods
|
||||
|
||||
logger.info(
|
||||
'Overpay API create_payment',
|
||||
merchant_transaction_id=merchant_transaction_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.post(
|
||||
f'{self.api_url}/orders/',
|
||||
json=payload,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200 or response.status_code == 201:
|
||||
logger.info(
|
||||
'Overpay API payment created',
|
||||
merchant_transaction_id=merchant_transaction_id,
|
||||
overpay_id=data.get('id'),
|
||||
result_url=data.get('resultUrl'),
|
||||
)
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'Overpay create_payment error',
|
||||
status_code=response.status_code,
|
||||
error_msg=error_msg,
|
||||
response_data=data,
|
||||
)
|
||||
raise OverpayAPIError(response.status_code, error_msg)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.exception('Overpay API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def get_payment(self, order_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Получает информацию о платеже по ID.
|
||||
GET {API_URL}/orders/{id}
|
||||
"""
|
||||
logger.info('Overpay get_payment', order_id=order_id)
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get(
|
||||
f'{self.api_url}/orders/{order_id}',
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'Overpay get_payment error',
|
||||
status_code=response.status_code,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
raise OverpayAPIError(response.status_code, error_msg)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.exception('Overpay API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def refund_payment(self, order_id: str, amount: str) -> dict[str, Any]:
|
||||
"""
|
||||
Возврат платежа.
|
||||
PUT {API_URL}/orders/{id}/refund
|
||||
"""
|
||||
logger.info('Overpay refund_payment', order_id=order_id, amount=amount)
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.put(
|
||||
f'{self.api_url}/orders/{order_id}/refund',
|
||||
json={'amount': amount},
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info('Overpay refund successful', order_id=order_id, amount=amount)
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'Overpay refund error',
|
||||
status_code=response.status_code,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
raise OverpayAPIError(response.status_code, error_msg)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.exception('Overpay API connection error', error=e)
|
||||
raise
|
||||
|
||||
|
||||
# Singleton instance
|
||||
overpay_service = OverpayService()
|
||||
@@ -12,6 +12,7 @@ from .freekassa import FreekassaPaymentMixin
|
||||
from .heleket import HeleketPaymentMixin
|
||||
from .kassa_ai import KassaAiPaymentMixin
|
||||
from .mulenpay import MulenPayPaymentMixin
|
||||
from .overpay import OverpayPaymentMixin
|
||||
from .pal24 import Pal24PaymentMixin
|
||||
from .paypear import PayPearPaymentMixin
|
||||
from .platega import PlategaPaymentMixin
|
||||
@@ -32,6 +33,7 @@ __all__ = [
|
||||
'HeleketPaymentMixin',
|
||||
'KassaAiPaymentMixin',
|
||||
'MulenPayPaymentMixin',
|
||||
'OverpayPaymentMixin',
|
||||
'Pal24PaymentMixin',
|
||||
'PayPearPaymentMixin',
|
||||
'PaymentCommonMixin',
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
"""Mixin для интеграции с Overpay (pay.overpay.io)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.overpay_service import overpay_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов Overpay -> internal
|
||||
OVERPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'charged': ('success', True),
|
||||
'authorized': ('authorized', False),
|
||||
'preflight': ('pending', False),
|
||||
'new': ('pending', False),
|
||||
'processing': ('processing', False),
|
||||
'prepared': ('processing', False),
|
||||
'rejected': ('rejected', False),
|
||||
'declined': ('declined', False),
|
||||
'reversed': ('reversed', False),
|
||||
'refunded': ('refunded', False),
|
||||
'chargeback': ('chargeback', False),
|
||||
'error': ('error', False),
|
||||
}
|
||||
|
||||
|
||||
class OverpayPaymentMixin:
|
||||
"""Mixin для работы с платежами Overpay."""
|
||||
|
||||
async def create_overpay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж Overpay.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_overpay_enabled():
|
||||
logger.error('Overpay не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.OVERPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Overpay: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
OVERPAY_MIN_AMOUNT_KOPEKS=settings.OVERPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.OVERPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Overpay: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
OVERPAY_MAX_AMOUNT_KOPEKS=settings.OVERPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'op{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
amount_value = f'{amount_rubles:.2f}'
|
||||
currency = settings.OVERPAY_CURRENCY
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
# Методы оплаты из настроек
|
||||
payment_methods_str = settings.OVERPAY_PAYMENT_METHODS
|
||||
payment_methods = (
|
||||
[m.strip() for m in payment_methods_str.split(',') if m.strip()] if payment_methods_str else None
|
||||
)
|
||||
|
||||
try:
|
||||
# Используем API для создания платежа
|
||||
result = await overpay_service.create_payment(
|
||||
amount=amount_value,
|
||||
currency=currency,
|
||||
lifetime_minutes=settings.OVERPAY_LIFETIME_MINUTES,
|
||||
merchant_transaction_id=order_id,
|
||||
description=description,
|
||||
return_url=return_url or settings.OVERPAY_RETURN_URL,
|
||||
payment_methods=payment_methods,
|
||||
)
|
||||
|
||||
payment_url = result.get('resultUrl')
|
||||
overpay_payment_id = str(result.get('id', '')) if result.get('id') else None
|
||||
|
||||
if not payment_url:
|
||||
logger.error('Overpay API не вернул URL платежа', result=result)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'Overpay API: создан платеж',
|
||||
order_id=order_id,
|
||||
overpay_payment_id=overpay_payment_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
# Срок действия
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=settings.OVERPAY_LIFETIME_MINUTES)
|
||||
|
||||
# Сохраняем в БД
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
local_payment = await overpay_crud.create_overpay_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
overpay_payment_id=overpay_payment_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Overpay: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'overpay_payment_id': overpay_payment_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Overpay: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
async def process_overpay_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает webhook от Overpay.
|
||||
|
||||
mTLS обеспечивает аутентификацию; дополнительно проверяем наличие платежа в БД.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело webhook
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
overpay_payment_id = str(payload.get('id', '')) if payload.get('id') else None
|
||||
merchant_transaction_id = payload.get('merchantTransactionId')
|
||||
overpay_status = payload.get('status')
|
||||
|
||||
if not overpay_payment_id or not overpay_status:
|
||||
logger.warning('Overpay webhook: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Ищем платеж по order_id (наш merchantTransactionId) или overpay_payment_id
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
payment = None
|
||||
if merchant_transaction_id:
|
||||
payment = await overpay_crud.get_overpay_payment_by_order_id(db, merchant_transaction_id)
|
||||
if not payment and overpay_payment_id:
|
||||
payment = await overpay_crud.get_overpay_payment_by_overpay_id(db, overpay_payment_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'Overpay webhook: платеж не найден',
|
||||
merchant_transaction_id=merchant_transaction_id,
|
||||
overpay_payment_id=overpay_payment_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await overpay_crud.get_overpay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Overpay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Проверка дублирования (re-check from locked row)
|
||||
if payment.is_paid:
|
||||
logger.info('Overpay webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = OVERPAY_STATUS_MAP.get(overpay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
callback_payload = {
|
||||
'overpay_payment_id': overpay_payment_id,
|
||||
'merchant_transaction_id': merchant_transaction_id,
|
||||
'status': overpay_status,
|
||||
}
|
||||
|
||||
# Финализируем платеж если оплачен — без промежуточного commit
|
||||
if is_paid:
|
||||
# Inline field assignments to keep FOR UPDATE lock intact
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.overpay_payment_id = overpay_payment_id or payment.overpay_payment_id
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_overpay_payment(
|
||||
db, payment, overpay_payment_id=overpay_payment_id, trigger='webhook'
|
||||
)
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
payment = await overpay_crud.update_overpay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
overpay_payment_id=overpay_payment_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Overpay webhook: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_overpay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
overpay_payment_id: str | None,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock must be acquired by the caller before invoking this method.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'Overpay платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
# Read fresh metadata AFTER lock to avoid stale data
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
# --- Guest purchase flow ---
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=str(overpay_payment_id) if overpay_payment_id else payment.order_id,
|
||||
provider_name='overpay',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
# Ensure paid fields are set (idempotent — caller may have already set them)
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для Overpay', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
# Загружаем промогруппы в асинхронном контексте
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = str(overpay_payment_id) if overpay_payment_id else payment.order_id
|
||||
|
||||
# Проверяем дупликат транзакции
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.OVERPAY,
|
||||
)
|
||||
|
||||
display_name = settings.get_overpay_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.OVERPAY,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await overpay_crud.link_overpay_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('Overpay платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.OVERPAY,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Overpay', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления Overpay', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'\u2705 <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю Overpay', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан Overpay платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_overpay_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Проверяет статус платежа через API."""
|
||||
try:
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
payment = await overpay_crud.get_overpay_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
logger.warning('Overpay payment not found', order_id=order_id)
|
||||
return None
|
||||
|
||||
if payment.is_paid:
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
# Проверяем через API по overpay_payment_id
|
||||
if payment.overpay_payment_id:
|
||||
try:
|
||||
order_data = await overpay_service.get_payment(payment.overpay_payment_id)
|
||||
overpay_status = order_data.get('status')
|
||||
|
||||
if overpay_status:
|
||||
status_info = OVERPAY_STATUS_MAP.get(overpay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
if is_paid:
|
||||
# Acquire FOR UPDATE lock before finalization
|
||||
locked = await overpay_crud.get_overpay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Overpay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return None
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('Overpay платеж уже обработан (api_check)', order_id=payment.order_id)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
logger.info('Overpay payment confirmed via API', order_id=payment.order_id)
|
||||
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.callback_payload = {
|
||||
'check_source': 'api',
|
||||
'overpay_order_data': order_data,
|
||||
}
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await self._finalize_overpay_payment(
|
||||
db,
|
||||
payment,
|
||||
overpay_payment_id=payment.overpay_payment_id,
|
||||
trigger='api_check',
|
||||
)
|
||||
elif internal_status != payment.status:
|
||||
# Обновляем статус если изменился
|
||||
payment = await overpay_crud.update_overpay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error checking Overpay payment status via API', error=e)
|
||||
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': payment.status or 'pending',
|
||||
'is_paid': payment.is_paid,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Overpay: ошибка проверки статуса', error=e)
|
||||
return None
|
||||
@@ -106,18 +106,30 @@ class PayPearPaymentMixin:
|
||||
)
|
||||
|
||||
confirmation = result.get('confirmation', {})
|
||||
payment_url = confirmation.get('url') if isinstance(confirmation, dict) else None
|
||||
payment_url = (
|
||||
(confirmation.get('confirmation_url') or confirmation.get('url'))
|
||||
if isinstance(confirmation, dict)
|
||||
else None
|
||||
)
|
||||
paypear_id = result.get('id')
|
||||
|
||||
if not payment_url:
|
||||
logger.error('PayPear API не вернул URL платежа', result=result)
|
||||
logger.error('PayPear API не вернул confirmation_url', result=result)
|
||||
return None
|
||||
|
||||
# PayPear может добавить комиссию к сумме — сохраняем фактическую сумму
|
||||
# для корректной проверки в webhook (amount включает комиссию)
|
||||
api_amount = result.get('amount', {})
|
||||
if isinstance(api_amount, dict) and api_amount.get('value') is not None:
|
||||
charged_kopeks = round(float(api_amount['value']) * 100)
|
||||
metadata['paypear_charged_kopeks'] = charged_kopeks
|
||||
|
||||
logger.info(
|
||||
'PayPear API: создан платеж',
|
||||
order_id=order_id,
|
||||
paypear_id=paypear_id,
|
||||
payment_url=payment_url,
|
||||
charged_kopeks=metadata.get('paypear_charged_kopeks'),
|
||||
)
|
||||
|
||||
# Срок действия — 30 минут по умолчанию
|
||||
@@ -239,6 +251,8 @@ class PayPearPaymentMixin:
|
||||
}
|
||||
|
||||
# Проверка суммы ДО обновления статуса
|
||||
# PayPear добавляет комиссию к amount — сравниваем с сохранённой суммой
|
||||
# (paypear_charged_kopeks), а не с исходной суммой пополнения
|
||||
if is_paid:
|
||||
amount_info = obj.get('amount', {})
|
||||
if isinstance(amount_info, dict):
|
||||
@@ -248,11 +262,14 @@ class PayPearPaymentMixin:
|
||||
|
||||
if amount_value is not None:
|
||||
received_kopeks = round(float(amount_value) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
payment_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
expected_kopeks = payment_metadata.get('paypear_charged_kopeks', payment.amount_kopeks)
|
||||
if abs(received_kopeks - expected_kopeks) > 1:
|
||||
logger.error(
|
||||
'PayPear amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
expected_kopeks=expected_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
original_amount_kopeks=payment.amount_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await paypear_crud.update_paypear_payment_status(
|
||||
@@ -539,16 +556,20 @@ class PayPearPaymentMixin:
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
if is_paid:
|
||||
# Проверка суммы
|
||||
# Проверка суммы — сравниваем с paypear_charged_kopeks
|
||||
# (amount включает комиссию PayPear)
|
||||
amount_info = order_data.get('amount', {})
|
||||
api_amount = amount_info.get('value') if isinstance(amount_info, dict) else amount_info
|
||||
if api_amount is not None:
|
||||
received_kopeks = round(float(api_amount) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
payment_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
expected_kopeks = payment_metadata.get('paypear_charged_kopeks', payment.amount_kopeks)
|
||||
if abs(received_kopeks - expected_kopeks) > 1:
|
||||
logger.error(
|
||||
'PayPear amount mismatch (API check)',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
expected_kopeks=expected_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
original_amount_kopeks=payment.amount_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await paypear_crud.update_paypear_payment_status(
|
||||
|
||||
@@ -169,6 +169,16 @@ def _get_method_defaults() -> dict:
|
||||
{'id': 'crypto', 'name': 'Криптовалюта'},
|
||||
],
|
||||
},
|
||||
'overpay': {
|
||||
'default_display_name': settings.get_overpay_display_name(),
|
||||
'is_configured': settings.is_overpay_enabled(),
|
||||
'default_min': settings.OVERPAY_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.OVERPAY_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'fps', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
'aurapay': {
|
||||
'default_display_name': settings.get_aurapay_display_name(),
|
||||
'is_configured': settings.is_aurapay_enabled(),
|
||||
@@ -223,6 +233,7 @@ DEFAULT_METHOD_ORDER = [
|
||||
'severpay',
|
||||
'paypear',
|
||||
'rollypay',
|
||||
'overpay',
|
||||
'aurapay',
|
||||
]
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.database.models import (
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
OverpayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethod,
|
||||
PlategaPayment,
|
||||
@@ -649,6 +650,39 @@ async def _search_severpay(db: AsyncSession, params: SearchParams) -> list[Pendi
|
||||
return records
|
||||
|
||||
|
||||
async def _search_overpay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(OverpayPayment).options(selectinload(OverpayPayment.user)).order_by(desc(OverpayPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, OverpayPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
OverpayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
OverpayPayment.overpay_payment_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, OverpayPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.OVERPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_stars(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
@@ -702,6 +736,7 @@ _PROVIDER_SEARCH_MAP: dict[PaymentMethod, Any] = {
|
||||
PaymentMethod.KASSA_AI: _search_kassa_ai,
|
||||
PaymentMethod.RIOPAY: _search_riopay,
|
||||
PaymentMethod.SEVERPAY: _search_severpay,
|
||||
PaymentMethod.OVERPAY: _search_overpay,
|
||||
PaymentMethod.TELEGRAM_STARS: _search_stars,
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from app.services.payment.aurapay import AuraPayPaymentMixin
|
||||
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
|
||||
from app.services.payment.freekassa import FreekassaPaymentMixin
|
||||
from app.services.payment.kassa_ai import KassaAiPaymentMixin
|
||||
from app.services.payment.overpay import OverpayPaymentMixin
|
||||
from app.services.payment.paypear import PayPearPaymentMixin
|
||||
from app.services.payment.riopay import RioPayPaymentMixin
|
||||
from app.services.payment.rollypay import RollyPayPaymentMixin
|
||||
@@ -408,6 +409,44 @@ async def link_rollypay_payment_to_transaction(*args, **kwargs):
|
||||
return await rollypay_crud.link_rollypay_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
# --- Overpay CRUD wrappers ---
|
||||
|
||||
|
||||
async def create_overpay_payment(*args, **kwargs):
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
return await overpay_crud.create_overpay_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_overpay_payment_by_order_id(*args, **kwargs):
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
return await overpay_crud.get_overpay_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_overpay_payment_by_overpay_id(*args, **kwargs):
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
return await overpay_crud.get_overpay_payment_by_overpay_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_overpay_payment_by_id(*args, **kwargs):
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
return await overpay_crud.get_overpay_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_overpay_payment_by_id_for_update(*args, **kwargs):
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
return await overpay_crud.get_overpay_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_overpay_payment_status(*args, **kwargs):
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
return await overpay_crud.update_overpay_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_overpay_payment_to_transaction(*args, **kwargs):
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
return await overpay_crud.link_overpay_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_aurapay_payment(*args, **kwargs):
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
return await aurapay_crud.create_aurapay_payment(*args, **kwargs)
|
||||
@@ -468,6 +507,7 @@ class PaymentService(
|
||||
SeverPayPaymentMixin,
|
||||
PayPearPaymentMixin,
|
||||
RollyPayPaymentMixin,
|
||||
OverpayPaymentMixin,
|
||||
AuraPayPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
@@ -932,6 +972,28 @@ class PaymentService(
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Overpay ----------------------------------------------------------
|
||||
if payment_method == 'overpay':
|
||||
if not settings.is_overpay_enabled():
|
||||
logger.warning('Overpay is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_overpay_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'overpay')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('overpay_payment_id') or result.get('order_id'),
|
||||
'provider': 'overpay',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- AuraPay ----------------------------------------------------------
|
||||
if payment_method == 'aurapay':
|
||||
if not settings.is_aurapay_enabled():
|
||||
|
||||
@@ -76,6 +76,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.KASSA_AI,
|
||||
PaymentMethod.RIOPAY,
|
||||
PaymentMethod.SEVERPAY,
|
||||
PaymentMethod.OVERPAY,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -96,6 +97,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.KASSA_AI,
|
||||
PaymentMethod.RIOPAY,
|
||||
PaymentMethod.SEVERPAY,
|
||||
PaymentMethod.OVERPAY,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -125,6 +127,8 @@ def method_display_name(method: PaymentMethod) -> str:
|
||||
return settings.get_riopay_display_name()
|
||||
if method == PaymentMethod.SEVERPAY:
|
||||
return settings.get_severpay_display_name()
|
||||
if method == PaymentMethod.OVERPAY:
|
||||
return settings.get_overpay_display_name()
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return 'Telegram Stars'
|
||||
return method.value
|
||||
@@ -155,6 +159,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
|
||||
return settings.is_riopay_enabled()
|
||||
if method == PaymentMethod.SEVERPAY:
|
||||
return settings.is_severpay_enabled()
|
||||
if method == PaymentMethod.OVERPAY:
|
||||
return settings.is_overpay_enabled()
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -202,16 +202,20 @@ class PricingEngine:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def get_tariff_daily_rate_fraction(tariff: Tariff, target_days: int) -> tuple[int, int]:
|
||||
def get_tariff_daily_rate_fraction(tariff: Tariff) -> tuple[int, int]:
|
||||
"""Дневная ставка тарифа как (price, period_days) для целочисленных вычислений.
|
||||
|
||||
Возвращает числитель и знаменатель дроби price/period_days,
|
||||
чтобы избежать float-ошибок в финансовых расчётах.
|
||||
|
||||
Всегда использует кратчайший доступный период тарифа, чтобы
|
||||
гарантировать корректное сравнение дневных ставок при смене
|
||||
тарифов с разными наборами периодов.
|
||||
"""
|
||||
periods = tariff.get_available_periods()
|
||||
if not periods:
|
||||
return 0, 1
|
||||
best_period = min(periods, key=lambda p: abs(p - target_days))
|
||||
best_period = min(periods)
|
||||
price = tariff.get_price_for_period(best_period)
|
||||
if not price or best_period <= 0:
|
||||
return 0, 1
|
||||
@@ -270,8 +274,8 @@ class PricingEngine:
|
||||
# raw_cost = (new_p/new_d - cur_p/cur_d) * remaining
|
||||
# = (new_p * cur_d - cur_p * new_d) * remaining / (new_d * cur_d)
|
||||
# Floor division (//) округляет дробные копейки вниз — в пользу пользователя.
|
||||
cur_price, cur_period = self.get_tariff_daily_rate_fraction(current_tariff, remaining_days)
|
||||
new_price, new_period = self.get_tariff_daily_rate_fraction(new_tariff, remaining_days)
|
||||
cur_price, cur_period = self.get_tariff_daily_rate_fraction(current_tariff)
|
||||
new_price, new_period = self.get_tariff_daily_rate_fraction(new_tariff)
|
||||
|
||||
numerator = (new_price * cur_period - cur_price * new_period) * remaining_days
|
||||
denominator = new_period * cur_period
|
||||
|
||||
@@ -378,11 +378,12 @@ class PromoCodeService:
|
||||
trial_tariff = await get_tariff(db, trial_tariff_id)
|
||||
|
||||
if trial_tariff:
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
trial_device_limit = trial_tariff.device_limit
|
||||
tariff_id_for_trial = trial_tariff.id
|
||||
if trial_tariff.allowed_squads:
|
||||
trial_squads = trial_tariff.allowed_squads
|
||||
trial_squads = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения тарифа для триального промокода', error=e)
|
||||
|
||||
|
||||
@@ -977,10 +977,8 @@ class RemnaWaveWebhookService:
|
||||
if subscription.subscription_crypto_link != subscription_crypto_link:
|
||||
subscription.subscription_crypto_link = subscription_crypto_link
|
||||
changed = True
|
||||
elif subscription_url and subscription.subscription_crypto_link:
|
||||
# URL обновился, а крипто-ссылка не пришла — сбрасываем старую
|
||||
subscription.subscription_crypto_link = None
|
||||
changed = True
|
||||
# NOTE: панель не включает cryptoLink в каждый webhook user.modified
|
||||
# Отсутствие поля не означает что его нужно сбрасывать
|
||||
|
||||
# Always stamp to protect from sync overwrite, even if no fields changed
|
||||
self._stamp_webhook_update(subscription)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""S2S Postback Service — sends server-to-server postbacks on events."""
|
||||
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
httpx = None
|
||||
|
||||
|
||||
def _is_enabled() -> bool:
|
||||
return getattr(settings, 'S2S_POSTBACK_ENABLED', False) and httpx is not None
|
||||
|
||||
|
||||
def _get_url(event: str) -> str | None:
|
||||
"""Get postback URL template for event type."""
|
||||
mapping = {
|
||||
'registration': getattr(settings, 'S2S_POSTBACK_REGISTRATION_URL', ''),
|
||||
'trial': getattr(settings, 'S2S_POSTBACK_TRIAL_URL', ''),
|
||||
'purchase': getattr(settings, 'S2S_POSTBACK_PURCHASE_URL', ''),
|
||||
}
|
||||
url = mapping.get(event, '')
|
||||
return url or None
|
||||
|
||||
|
||||
async def send_postback(
|
||||
event: str,
|
||||
subid: str,
|
||||
amount: float | None = None,
|
||||
user_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Send S2S postback for an event.
|
||||
|
||||
Args:
|
||||
event: 'registration', 'trial', or 'purchase'
|
||||
subid: tracking subid from URL
|
||||
amount: purchase amount in rubles (for purchase event)
|
||||
user_id: internal user ID for logging
|
||||
|
||||
Returns:
|
||||
True if sent successfully
|
||||
"""
|
||||
if not _is_enabled():
|
||||
return False
|
||||
|
||||
if not subid:
|
||||
return False
|
||||
|
||||
url_template = _get_url(event)
|
||||
if not url_template:
|
||||
logger.debug('S2S postback URL not configured', event=event)
|
||||
return False
|
||||
|
||||
# Replace placeholders (URL-encode subid to prevent injection)
|
||||
from urllib.parse import quote
|
||||
|
||||
url = url_template.replace('{subid}', quote(subid, safe=''))
|
||||
url = url.replace('{event}', event)
|
||||
if amount is not None:
|
||||
url = url.replace('{amount}', str(round(amount, 2)))
|
||||
else:
|
||||
url = url.replace('{amount}', '0')
|
||||
|
||||
url = url.replace('{user_id}', str(user_id) if user_id is not None else '0')
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(url)
|
||||
logger.info(
|
||||
'S2S postback sent',
|
||||
event=event,
|
||||
subid=subid,
|
||||
amount=amount,
|
||||
user_id=user_id,
|
||||
status_code=response.status_code,
|
||||
url=url[:100],
|
||||
)
|
||||
return response.status_code < 400
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'S2S postback failed',
|
||||
event=event,
|
||||
subid=subid,
|
||||
error=str(e),
|
||||
url=url[:100],
|
||||
)
|
||||
return False
|
||||
@@ -488,6 +488,7 @@ class SubscriptionRenewalService:
|
||||
)
|
||||
|
||||
reset_traffic = was_expired and settings.RESET_TRAFFIC_ON_PAYMENT
|
||||
reset_devices = settings.RESET_DEVICES_ON_RENEWAL
|
||||
subscription_service = SubscriptionService()
|
||||
try:
|
||||
await db.refresh(user)
|
||||
@@ -526,6 +527,28 @@ class SubscriptionRenewalService:
|
||||
action='create' if not getattr(subscription_after, 'remnawave_uuid', None) else 'update',
|
||||
)
|
||||
|
||||
# Сброс привязанных устройств при продлении (если включено)
|
||||
if reset_devices:
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
rw_service = RemnaWaveService()
|
||||
_uuid = (
|
||||
getattr(subscription_after, 'remnawave_uuid', None)
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else getattr(user, 'remnawave_uuid', None)
|
||||
)
|
||||
if _uuid:
|
||||
async with rw_service.get_api_client() as api:
|
||||
await api.reset_user_devices(_uuid)
|
||||
logger.info(
|
||||
'Devices reset on renewal',
|
||||
subscription_id=subscription_after.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Failed to reset devices on renewal', error=error, exc_info=True)
|
||||
|
||||
transaction: Transaction | None = None
|
||||
try:
|
||||
transaction = await create_transaction(
|
||||
|
||||
@@ -93,6 +93,7 @@ class BotConfigurationService:
|
||||
'SEVERPAY': '💳 SeverPay',
|
||||
'PAYPEAR': '💳 PayPear',
|
||||
'ROLLYPAY': '💳 RollyPay',
|
||||
'OVERPAY': '💳 Overpay',
|
||||
'AURAPAY': '💳 AuraPay',
|
||||
'YOOKASSA': '🟣 YooKassa',
|
||||
'PLATEGA': '💳 {platega_name}',
|
||||
@@ -157,6 +158,7 @@ class BotConfigurationService:
|
||||
'RIOPAY': 'RioPay: платёжная система api.riopay.online с поддержкой карт и СБП.',
|
||||
'PAYPEAR': 'PayPear: платёжная система api.paypear.ru с поддержкой карт, СБП, SberPay и T-Pay.',
|
||||
'ROLLYPAY': 'RollyPay: платёжный шлюз rollypay.io с СБП, картами и криптовалютой.',
|
||||
'OVERPAY': 'Overpay: платёжный шлюз pay.overpay.io с mTLS и поддержкой карт и СБП.',
|
||||
'AURAPAY': 'AuraPay: платёжный шлюз aurapay.tech с поддержкой карт и СБП.',
|
||||
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
|
||||
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
|
||||
@@ -371,6 +373,7 @@ class BotConfigurationService:
|
||||
'SEVERPAY_': 'SEVERPAY',
|
||||
'PAYPEAR_': 'PAYPEAR',
|
||||
'ROLLYPAY_': 'ROLLYPAY',
|
||||
'OVERPAY_': 'OVERPAY',
|
||||
'AURAPAY_': 'AURAPAY',
|
||||
'PLATEGA_': 'PLATEGA',
|
||||
'MULENPAY_': 'MULENPAY',
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Yandex.Metrika offline conversions service.
|
||||
|
||||
Sends events (registration, trial-add, purchase) to mc.yandex.ru/collect
|
||||
using the Measurement Protocol. No pageview needed — user has active
|
||||
Metrika session from the site. yclid is passed via landing page URL,
|
||||
Metrika matches it automatically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.yandex_client_id import (
|
||||
get_cid,
|
||||
mark_registration_sent,
|
||||
mark_trial_sent,
|
||||
upsert_cid,
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
COLLECT_URL = 'https://mc.yandex.ru/collect'
|
||||
TIMEOUT = 10.0
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY = 1.0
|
||||
|
||||
_CID_RE = re.compile(r'^[A-Za-z0-9._:-]{4,128}$')
|
||||
_http_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def _get_client() -> httpx.AsyncClient:
|
||||
global _http_client
|
||||
if _http_client is None or _http_client.is_closed:
|
||||
_http_client = httpx.AsyncClient(timeout=TIMEOUT)
|
||||
return _http_client
|
||||
|
||||
|
||||
def _is_enabled() -> bool:
|
||||
return bool(
|
||||
settings.YANDEX_OFFLINE_CONV_ENABLED
|
||||
and settings.YANDEX_OFFLINE_CONV_COUNTER_ID
|
||||
and settings.YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET
|
||||
)
|
||||
|
||||
|
||||
def _normalize_cid(cid: str | None) -> str | None:
|
||||
if not isinstance(cid, str):
|
||||
return None
|
||||
cid = cid.strip()
|
||||
if not cid or not _CID_RE.match(cid):
|
||||
return None
|
||||
return cid
|
||||
|
||||
|
||||
def _mask_cid(cid: str) -> str:
|
||||
if len(cid) <= 4:
|
||||
return '****'
|
||||
return '*' * (len(cid) - 4) + cid[-4:]
|
||||
|
||||
|
||||
def _base_payload(cid: str) -> dict[str, str]:
|
||||
return {
|
||||
'tid': settings.YANDEX_OFFLINE_CONV_COUNTER_ID,
|
||||
'cid': cid,
|
||||
'ms': settings.YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET,
|
||||
}
|
||||
|
||||
|
||||
def _pageview_payload(cid: str) -> dict[str, str]:
|
||||
payload = _base_payload(cid)
|
||||
payload.update(
|
||||
{
|
||||
't': 'pageview',
|
||||
'dl': settings.YANDEX_OFFLINE_CONV_DL or 'https://web.mtrxvps.ru',
|
||||
'dt': settings.YANDEX_OFFLINE_CONV_DT or 'Matrixxx VPN',
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _event_payload(cid: str, event_action: str) -> dict[str, str]:
|
||||
payload = _base_payload(cid)
|
||||
payload.update(
|
||||
{
|
||||
't': 'event',
|
||||
'ea': event_action,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _ecommerce_purchase_payload(
|
||||
cid: str,
|
||||
amount_rubles: float,
|
||||
order_id: str = '',
|
||||
product_name: str = '',
|
||||
product_category: str = '',
|
||||
) -> dict[str, str]:
|
||||
"""Build ecommerce:purchase payload for Metrika Measurement Protocol."""
|
||||
|
||||
service_name = (
|
||||
getattr(settings, 'YANDEX_OFFLINE_CONV_DT', '')
|
||||
or getattr(settings, 'PAYMENT_SERVICE_NAME', '')
|
||||
or 'Subscription'
|
||||
)
|
||||
currency = getattr(settings, 'YANDEX_OFFLINE_CONV_CURRENCY', '') or 'RUB'
|
||||
payload = _base_payload(cid)
|
||||
payload.update(
|
||||
{
|
||||
't': 'event',
|
||||
'ea': 'purchase',
|
||||
'pa': 'purchase',
|
||||
'ti': order_id or str(int(time.time())),
|
||||
'tr': str(amount_rubles),
|
||||
'cu': currency,
|
||||
'ev': str(amount_rubles),
|
||||
'pr1id': 'subscription',
|
||||
'pr1nm': product_name or service_name,
|
||||
'pr1ca': product_category or 'subscription',
|
||||
'pr1pr': str(amount_rubles),
|
||||
'pr1qt': '1',
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
async def _post_collect(payload: dict[str, str], kind: str, cid: str) -> bool:
|
||||
"""POST to mc.yandex.ru/collect with retries. Returns True on success."""
|
||||
masked = _mask_cid(cid)
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
client = _get_client()
|
||||
resp = await client.post(COLLECT_URL, data=payload)
|
||||
|
||||
if 200 <= resp.status_code < 300:
|
||||
logger.info('collect sent', kind=kind, cid=masked, status=resp.status_code)
|
||||
return True
|
||||
|
||||
if 500 <= resp.status_code < 600 and attempt < MAX_RETRIES:
|
||||
logger.warning(
|
||||
'collect server error',
|
||||
kind=kind,
|
||||
attempt=attempt,
|
||||
max=MAX_RETRIES,
|
||||
cid=masked,
|
||||
status=resp.status_code,
|
||||
)
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
continue
|
||||
|
||||
logger.error('collect rejected', kind=kind, cid=masked, status=resp.status_code, body=resp.text[:200])
|
||||
return False
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
'collect request error', kind=kind, attempt=attempt, max=MAX_RETRIES, cid=masked, error=str(exc)
|
||||
)
|
||||
if attempt < MAX_RETRIES:
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
continue
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def _send_event(cid: str, event_action: str) -> bool:
|
||||
"""Send event directly — no pageview needed, user has active Metrika session."""
|
||||
return await _post_collect(_event_payload(cid, event_action), event_action, cid)
|
||||
|
||||
|
||||
# --- Background task helpers ---
|
||||
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _task_done(task):
|
||||
"""Log errors from background conversion tasks."""
|
||||
_background_tasks.discard(task)
|
||||
if task.cancelled():
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc:
|
||||
logger.error('YandexOfflineConv background task failed', error=str(exc))
|
||||
|
||||
|
||||
def spawn_bg(coro) -> None:
|
||||
"""Spawn a background Yandex conversion task with proper reference tracking.
|
||||
|
||||
Checks _is_enabled() early so callers don't need to.
|
||||
"""
|
||||
if not _is_enabled():
|
||||
# Close the coroutine to avoid RuntimeWarning
|
||||
coro.close()
|
||||
return
|
||||
task = asyncio.create_task(coro)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_task_done)
|
||||
|
||||
|
||||
async def _fire_bg(event_name: str, event_fn, user_id: int, **kwargs) -> None:
|
||||
"""Generic background wrapper: opens a session, calls event_fn, logs errors."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await event_fn(db, user_id, **kwargs)
|
||||
except Exception as exc:
|
||||
logger.warning('YandexOfflineConv background event failed', event=event_name, user_id=user_id, error=str(exc))
|
||||
|
||||
|
||||
async def fire_registration_bg(user_id: int) -> None:
|
||||
"""Fire registration event in background with its own DB session."""
|
||||
await _fire_bg('registration', on_registration, user_id)
|
||||
|
||||
|
||||
async def fire_trial_bg(user_id: int) -> None:
|
||||
"""Fire trial event in background with its own DB session."""
|
||||
await _fire_bg('trial', on_trial, user_id)
|
||||
|
||||
|
||||
async def fire_purchase_bg(user_id: int, amount_kopeks: int) -> None:
|
||||
"""Fire purchase event in background with its own DB session."""
|
||||
await _fire_bg('purchase', on_purchase, user_id, amount_kopeks=amount_kopeks)
|
||||
|
||||
|
||||
# --- Public API ---
|
||||
async def store_cid(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
cid: str | None,
|
||||
source: str = 'web',
|
||||
) -> bool:
|
||||
"""Store Yandex ClientID for a user. Returns True if stored."""
|
||||
normalized = _normalize_cid(cid)
|
||||
if not normalized:
|
||||
return False
|
||||
|
||||
try:
|
||||
await upsert_cid(db, user_id, normalized, source=source, counter_id=settings.YANDEX_OFFLINE_CONV_COUNTER_ID)
|
||||
logger.info('stored CID', user_id=user_id, source=source)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error('failed to store CID', user_id=user_id, error=str(exc))
|
||||
return False
|
||||
|
||||
|
||||
async def store_cid_and_fire_registration(
|
||||
user_id: int,
|
||||
cid: str | None,
|
||||
*,
|
||||
source: str = 'web',
|
||||
) -> None:
|
||||
"""Store Yandex CID and fire registration conversion in background (best-effort).
|
||||
|
||||
Opens its own DB session so it never interferes with the caller's transaction.
|
||||
"""
|
||||
if not cid:
|
||||
return
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
stored = await store_cid(db, user_id, cid, source=source)
|
||||
if stored:
|
||||
await db.commit()
|
||||
spawn_bg(fire_registration_bg(user_id))
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to store CID and fire registration', user_id=user_id, error=str(exc))
|
||||
|
||||
|
||||
async def on_registration(db: AsyncSession, user_id: int) -> None:
|
||||
"""Fire registration event (once per user)."""
|
||||
if not _is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
row = await get_cid(db, user_id)
|
||||
if not row or row.registration_sent:
|
||||
return
|
||||
if not row.yandex_cid or row.yandex_cid.startswith('_'):
|
||||
return # placeholder row — real CID not yet received
|
||||
|
||||
success = await _send_event(row.yandex_cid, 'registration')
|
||||
if success:
|
||||
await mark_registration_sent(db, user_id)
|
||||
await db.commit()
|
||||
logger.info('registration event sent', user_id=user_id)
|
||||
except Exception as exc:
|
||||
logger.error('registration event failed', user_id=user_id, error=str(exc))
|
||||
|
||||
|
||||
async def on_trial(db: AsyncSession, user_id: int) -> None:
|
||||
"""Fire trial-add event (once per user)."""
|
||||
if not _is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
row = await get_cid(db, user_id)
|
||||
if not row or row.trial_sent:
|
||||
return
|
||||
if not row.yandex_cid or row.yandex_cid.startswith('_'):
|
||||
return # placeholder row — real CID not yet received
|
||||
|
||||
success = await _send_event(row.yandex_cid, 'trial-add')
|
||||
if success:
|
||||
await mark_trial_sent(db, user_id)
|
||||
await db.commit()
|
||||
logger.info('trial-add event sent', user_id=user_id)
|
||||
except Exception as exc:
|
||||
logger.error('trial-add event failed', user_id=user_id, error=str(exc))
|
||||
|
||||
|
||||
async def on_purchase(db: AsyncSession, user_id: int, amount_kopeks: int) -> None:
|
||||
"""Fire ecommerce purchase event (every payment)."""
|
||||
if not _is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
row = await get_cid(db, user_id)
|
||||
if not row:
|
||||
return
|
||||
if not row.yandex_cid or row.yandex_cid.startswith('_'):
|
||||
return # placeholder row — real CID not yet received
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
payload = _ecommerce_purchase_payload(row.yandex_cid, amount_rubles)
|
||||
success = await _post_collect(payload, 'purchase', row.yandex_cid)
|
||||
if success:
|
||||
logger.info('purchase event sent', user_id=user_id, amount=amount_rubles)
|
||||
except Exception as exc:
|
||||
logger.error('purchase event failed', user_id=user_id, error=str(exc))
|
||||
|
||||
|
||||
def parse_cid_from_start_param(param: str) -> tuple[str | None, str]:
|
||||
"""Extract Yandex CID from bot start parameter.
|
||||
|
||||
If param starts with the configured prefix (e.g. 'utm_ya_'),
|
||||
returns (cid, original_param). Otherwise returns (None, original_param).
|
||||
Original param is always preserved for UTM tracking.
|
||||
"""
|
||||
prefix = settings.YANDEX_OFFLINE_CONV_START_PREFIX
|
||||
if not prefix or not param.startswith(prefix):
|
||||
return None, param
|
||||
|
||||
cid = param[len(prefix) :]
|
||||
normalized = _normalize_cid(cid)
|
||||
return normalized, param # Keep original param for UTM tracking
|
||||
@@ -143,8 +143,6 @@ async def update_menu_layout(
|
||||
btn_dict = btn.model_dump()
|
||||
# Автоматически определяем наличие плейсхолдеров, если dynamic_text не установлен
|
||||
if not btn_dict.get('dynamic_text', False):
|
||||
from app.services.menu_layout.service import MenuLayoutService
|
||||
|
||||
btn_dict['dynamic_text'] = MenuLayoutService._text_has_placeholders(btn_dict.get('text', {}))
|
||||
buttons_config[btn_id] = btn_dict
|
||||
config['buttons'] = buttons_config
|
||||
@@ -306,8 +304,6 @@ async def add_custom_button(
|
||||
# Автоматически определяем наличие плейсхолдеров, если dynamic_text не установлен
|
||||
dynamic_text = payload.dynamic_text
|
||||
if not dynamic_text:
|
||||
from app.services.menu_layout.service import MenuLayoutService
|
||||
|
||||
dynamic_text = MenuLayoutService._text_has_placeholders(payload.text)
|
||||
|
||||
button_config = {
|
||||
|
||||
@@ -3834,9 +3834,11 @@ async def activate_subscription_trial_endpoint(
|
||||
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
|
||||
|
||||
if trial_tariff:
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
trial_device_limit = trial_tariff.device_limit
|
||||
trial_squads = trial_tariff.allowed_squads or []
|
||||
trial_squads = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
|
||||
tariff_id_for_trial = trial_tariff.id
|
||||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
||||
from app.database.crud.subscription import (
|
||||
add_subscription_devices,
|
||||
add_subscription_squad,
|
||||
@@ -75,16 +75,16 @@ async def _choose_trial_squads(
|
||||
return fallback_squads
|
||||
|
||||
try:
|
||||
squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
default_squads = await get_effective_tariff_squad_uuids(db, None)
|
||||
except Exception as error:
|
||||
logger.error('Failed to select trial squad', error=error)
|
||||
squad_uuid = None
|
||||
logger.error('Failed to resolve default trial squads', error=error)
|
||||
default_squads = []
|
||||
|
||||
if not squad_uuid:
|
||||
if not default_squads:
|
||||
return []
|
||||
|
||||
logger.debug('Selected trial squad for subscription replacement', squad_uuid=squad_uuid)
|
||||
return [squad_uuid]
|
||||
logger.debug('Selected default trial squads for subscription replacement', squad_uuids=default_squads)
|
||||
return default_squads
|
||||
|
||||
|
||||
async def _get_subscription(db: AsyncSession, subscription_id: int) -> Subscription:
|
||||
|
||||
@@ -54,6 +54,7 @@ class ButtonConditions(BaseModel):
|
||||
show_trial: bool | None = Field(default=None, description='Показать пробный период')
|
||||
show_buy: bool | None = Field(default=None, description='Показать кнопку покупки')
|
||||
has_saved_cart: bool | None = Field(default=None, description='Есть сохраненная корзина')
|
||||
traffic_topup_enabled: bool | None = Field(default=None, description='Докупка трафика включена')
|
||||
|
||||
# Расширенные условия
|
||||
min_balance_kopeks: int | None = Field(default=None, ge=0, description='Минимальный баланс в копейках')
|
||||
@@ -84,7 +85,7 @@ class MenuButtonConfig(BaseModel):
|
||||
type: ButtonType = Field(..., description='Тип кнопки')
|
||||
builtin_id: str | None = Field(default=None, description='ID встроенной кнопки (для type=builtin)')
|
||||
text: dict[str, str] = Field(..., description='Локализованные тексты кнопки: {lang_code: text}')
|
||||
icon: str | None = Field(default=None, max_length=10, description='Эмодзи/иконка кнопки (отдельно от текста)')
|
||||
icon: str | None = Field(default=None, max_length=100, description='Эмодзи/иконка кнопки (отдельно от текста)')
|
||||
action: str = Field(..., description='callback_data или URL в зависимости от типа')
|
||||
enabled: bool = Field(default=True, description='Кнопка активна')
|
||||
visibility: ButtonVisibility = Field(default=ButtonVisibility.ALL, description='Видимость кнопки')
|
||||
@@ -172,7 +173,7 @@ class ButtonUpdateRequest(BaseModel):
|
||||
"""Запрос на обновление отдельной кнопки."""
|
||||
|
||||
text: dict[str, str] | None = Field(default=None, description='Новые локализованные тексты')
|
||||
icon: str | None = Field(default=None, max_length=10, description='Эмодзи/иконка кнопки')
|
||||
icon: str | None = Field(default=None, max_length=100, description='Эмодзи/иконка кнопки')
|
||||
enabled: bool | None = Field(default=None, description='Включить/выключить')
|
||||
visibility: ButtonVisibility | None = Field(default=None, description='Новая видимость')
|
||||
conditions: ButtonConditions | None = Field(default=None, description='Новые условия показа')
|
||||
@@ -212,7 +213,7 @@ class AddCustomButtonRequest(BaseModel):
|
||||
id: str = Field(..., min_length=1, max_length=50, description='ID кнопки (уникальный)')
|
||||
type: ButtonType = Field(..., description='Тип кнопки (url, mini_app или callback)')
|
||||
text: dict[str, str] = Field(..., description='Локализованные тексты')
|
||||
icon: str | None = Field(default=None, max_length=10, description='Эмодзи/иконка кнопки')
|
||||
icon: str | None = Field(default=None, max_length=100, description='Эмодзи/иконка кнопки')
|
||||
action: str = Field(..., min_length=1, description='URL или callback_data')
|
||||
visibility: ButtonVisibility = Field(default=ButtonVisibility.ALL, description='Видимость')
|
||||
conditions: ButtonConditions | None = Field(default=None, description='Условия показа')
|
||||
|
||||
@@ -5,6 +5,12 @@ from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TicketMediaItemResponse(BaseModel):
|
||||
type: str
|
||||
file_id: str
|
||||
caption: str | None = None
|
||||
|
||||
|
||||
class TicketMessageResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
@@ -14,6 +20,7 @@ class TicketMessageResponse(BaseModel):
|
||||
media_type: str | None = None
|
||||
media_file_id: str | None = None
|
||||
media_caption: str | None = None
|
||||
media_items: list[TicketMediaItemResponse] | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
|
||||
@@ -1340,6 +1340,81 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Overpay webhook
|
||||
if settings.is_overpay_enabled():
|
||||
|
||||
@router.get(settings.OVERPAY_WEBHOOK_PATH)
|
||||
async def overpay_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'overpay_webhook',
|
||||
'enabled': settings.is_overpay_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.OVERPAY_WEBHOOK_PATH)
|
||||
async def overpay_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Overpay webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Overpay uses mTLS for authentication — verify payment exists in DB
|
||||
merchant_transaction_id = payload.get('merchantTransactionId')
|
||||
if not merchant_transaction_id:
|
||||
logger.warning('Overpay webhook: missing merchantTransactionId')
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Validate that the payment exists in our DB (basic anti-spoofing)
|
||||
from app.database.crud.overpay import get_overpay_payment_by_order_id
|
||||
|
||||
db_generator = get_db()
|
||||
try:
|
||||
check_db = await db_generator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
try:
|
||||
existing = await get_overpay_payment_by_order_id(check_db, merchant_transaction_id)
|
||||
if not existing:
|
||||
overpay_id = payload.get('id')
|
||||
if overpay_id:
|
||||
from app.database.crud.overpay import get_overpay_payment_by_overpay_id
|
||||
|
||||
existing = await get_overpay_payment_by_overpay_id(check_db, str(overpay_id))
|
||||
if not existing:
|
||||
logger.warning(
|
||||
'Overpay webhook: payment not found in DB',
|
||||
merchant_transaction_id=merchant_transaction_id,
|
||||
)
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_404_NOT_FOUND)
|
||||
finally:
|
||||
try:
|
||||
await db_generator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_overpay_webhook',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Overpay webhook processing failed',
|
||||
data=payload.get('id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Overpay webhook processing error', error=e)
|
||||
# Always return 200 — Overpay expects HTTP 200
|
||||
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# AuraPay webhook
|
||||
if settings.is_aurapay_enabled():
|
||||
|
||||
@@ -1411,6 +1486,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
'severpay_enabled': settings.is_severpay_enabled(),
|
||||
'paypear_enabled': settings.is_paypear_enabled(),
|
||||
'rollypay_enabled': settings.is_rollypay_enabled(),
|
||||
'overpay_enabled': settings.is_overpay_enabled(),
|
||||
'aurapay_enabled': settings.is_aurapay_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -607,6 +607,7 @@ async def main():
|
||||
secret_token=settings.WEBHOOK_SECRET_TOKEN,
|
||||
drop_pending_updates=False, # Обрабатываем накопившиеся обновления
|
||||
allowed_updates=allowed_updates,
|
||||
**({'ip_address': settings.WEBHOOK_IP} if settings.WEBHOOK_IP else {}),
|
||||
)
|
||||
stage.log(f'Webhook установлен: {webhook_url}')
|
||||
stage.log(f'Allowed updates: {", ".join(sorted(allowed_updates)) if allowed_updates else "all"}')
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""add ticket_messages.media_items for multi-media bubbles
|
||||
|
||||
Revision ID: 0061
|
||||
Revises: 0060
|
||||
Create Date: 2026-04-22
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = '0061'
|
||||
down_revision: Union[str, None] = '0060'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
'SELECT EXISTS (SELECT 1 FROM information_schema.columns '
|
||||
"WHERE table_name = 'ticket_messages' AND column_name = 'media_items')"
|
||||
)
|
||||
)
|
||||
if not result.scalar():
|
||||
op.add_column(
|
||||
'ticket_messages',
|
||||
sa.Column(
|
||||
'media_items',
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
'SELECT EXISTS (SELECT 1 FROM information_schema.columns '
|
||||
"WHERE table_name = 'ticket_messages' AND column_name = 'media_items')"
|
||||
)
|
||||
)
|
||||
if result.scalar():
|
||||
op.drop_column('ticket_messages', 'media_items')
|
||||
@@ -0,0 +1,62 @@
|
||||
"""add landing_pages analytics columns + sticky_pay_button
|
||||
|
||||
Revision ID: 0062
|
||||
Revises: 0061
|
||||
Create Date: 2026-04-22
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0062'
|
||||
down_revision: Union[str, None] = '0061'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
NEW_COLUMNS = (
|
||||
(
|
||||
'sticky_pay_button',
|
||||
sa.Column('sticky_pay_button', sa.Boolean(), nullable=False, server_default=sa.text('false')),
|
||||
),
|
||||
(
|
||||
'analytics_view_enabled',
|
||||
sa.Column('analytics_view_enabled', sa.Boolean(), nullable=False, server_default=sa.text('false')),
|
||||
),
|
||||
('analytics_view_goal', sa.Column('analytics_view_goal', sa.String(64), nullable=True)),
|
||||
(
|
||||
'analytics_click_enabled',
|
||||
sa.Column('analytics_click_enabled', sa.Boolean(), nullable=False, server_default=sa.text('false')),
|
||||
),
|
||||
('analytics_click_goal', sa.Column('analytics_click_goal', sa.String(64), nullable=True)),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for col_name, col_def in NEW_COLUMNS:
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
'SELECT EXISTS (SELECT 1 FROM information_schema.columns '
|
||||
"WHERE table_name = 'landing_pages' AND column_name = :col)"
|
||||
),
|
||||
{'col': col_name},
|
||||
)
|
||||
if not result.scalar():
|
||||
op.add_column('landing_pages', col_def)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for col_name, _ in reversed(NEW_COLUMNS):
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
'SELECT EXISTS (SELECT 1 FROM information_schema.columns '
|
||||
"WHERE table_name = 'landing_pages' AND column_name = :col)"
|
||||
),
|
||||
{'col': col_name},
|
||||
)
|
||||
if result.scalar():
|
||||
op.drop_column('landing_pages', col_name)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""add yandex_client_id_map table + guest_purchases offline conv columns
|
||||
|
||||
Revision ID: 0063
|
||||
Revises: 0062
|
||||
Create Date: 2026-04-21
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0063'
|
||||
down_revision: Union[str, None] = '0062'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1) yandex_client_id_map — created idempotently
|
||||
result = conn.execute(
|
||||
sa.text("SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'yandex_client_id_map')")
|
||||
)
|
||||
if not result.scalar():
|
||||
op.create_table(
|
||||
'yandex_client_id_map',
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
'user_id',
|
||||
sa.Integer,
|
||||
sa.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('yandex_cid', sa.String(128), nullable=False),
|
||||
sa.Column('source', sa.String(20), nullable=False, server_default='web'),
|
||||
sa.Column('counter_id', sa.String(32), nullable=True),
|
||||
sa.Column(
|
||||
'registration_sent',
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
server_default=sa.text('false'),
|
||||
),
|
||||
sa.Column(
|
||||
'trial_sent',
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
server_default=sa.text('false'),
|
||||
),
|
||||
sa.Column('subid', sa.String(255), nullable=True),
|
||||
sa.Column(
|
||||
'created_at',
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
'updated_at',
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
|
||||
# 2) guest_purchases — add yandex_cid / subid / referrer (idempotent)
|
||||
for col_name, col_def in (
|
||||
('yandex_cid', sa.Column('yandex_cid', sa.String(128), nullable=True)),
|
||||
('subid', sa.Column('subid', sa.String(255), nullable=True)),
|
||||
('referrer', sa.Column('referrer', sa.String(500), nullable=True)),
|
||||
):
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
'SELECT EXISTS (SELECT 1 FROM information_schema.columns '
|
||||
"WHERE table_name = 'guest_purchases' AND column_name = :col)"
|
||||
),
|
||||
{'col': col_name},
|
||||
)
|
||||
if not result.scalar():
|
||||
op.add_column('guest_purchases', col_def)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for col_name in ('referrer', 'subid', 'yandex_cid'):
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
'SELECT EXISTS (SELECT 1 FROM information_schema.columns '
|
||||
"WHERE table_name = 'guest_purchases' AND column_name = :col)"
|
||||
),
|
||||
{'col': col_name},
|
||||
)
|
||||
if result.scalar():
|
||||
op.drop_column('guest_purchases', col_name)
|
||||
|
||||
result = conn.execute(
|
||||
sa.text("SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'yandex_client_id_map')")
|
||||
)
|
||||
if result.scalar():
|
||||
op.drop_table('yandex_client_id_map')
|
||||
@@ -0,0 +1,45 @@
|
||||
"""create overpay_payments table
|
||||
|
||||
Revision ID: 0064
|
||||
Revises: 0063
|
||||
Create Date: 2026-04-21
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0064'
|
||||
down_revision: Union[str, None] = '0063'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'overpay_payments',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True),
|
||||
sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True),
|
||||
sa.Column('overpay_payment_id', sa.String(128), unique=True, nullable=True, index=True),
|
||||
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
|
||||
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
|
||||
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||
sa.Column('payment_url', sa.Text(), nullable=True),
|
||||
sa.Column('payment_method', sa.String(32), nullable=True),
|
||||
sa.Column('metadata_json', sa.JSON(), nullable=True),
|
||||
sa.Column('callback_payload', sa.JSON(), nullable=True),
|
||||
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('overpay_payments')
|
||||
@@ -0,0 +1,47 @@
|
||||
"""create info_pages table
|
||||
|
||||
Revision ID: 0065
|
||||
Revises: 0064
|
||||
Create Date: 2026-04-21
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0065'
|
||||
down_revision: Union[str, None] = '0064'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _table_exists('info_pages'):
|
||||
op.create_table(
|
||||
'info_pages',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('slug', sa.String(200), unique=True, nullable=False),
|
||||
sa.Column('title', sa.dialects.postgresql.JSONB(), nullable=False, server_default='{}'),
|
||||
sa.Column('content', sa.dialects.postgresql.JSONB(), nullable=False, server_default='{}'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('icon', sa.String(50), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('info_pages')
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
"""Check if a table already exists (idempotent migration guard)."""
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text('SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = :name)'),
|
||||
{'name': table_name},
|
||||
)
|
||||
return result.scalar()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add page_type to info_pages
|
||||
|
||||
Revision ID: 0066
|
||||
Revises: 0065
|
||||
Create Date: 2026-04-21
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0066'
|
||||
down_revision: Union[str, None] = '0065'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'info_pages',
|
||||
sa.Column('page_type', sa.String(20), nullable=False, server_default='page'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('info_pages', 'page_type')
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add replaces_tab to info_pages
|
||||
|
||||
Revision ID: 0067
|
||||
Revises: 0066
|
||||
Create Date: 2026-04-21
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0067'
|
||||
down_revision: Union[str, None] = '0066'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'info_pages',
|
||||
sa.Column('replaces_tab', sa.String(20), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('info_pages', 'replaces_tab')
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.49.0"
|
||||
version = "3.52.0"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from app.database.crud.subscription import create_trial_subscription
|
||||
|
||||
|
||||
async def test_create_trial_subscription_uses_all_available_squads_by_default(monkeypatch):
|
||||
db = Mock()
|
||||
db.add = Mock()
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
|
||||
monkeypatch.setattr('app.database.crud.subscription.get_subscription_by_user_id', AsyncMock(return_value=None))
|
||||
monkeypatch.setattr('app.database.crud.subscription.generate_unique_short_id', AsyncMock(return_value='abc123'))
|
||||
monkeypatch.setattr(
|
||||
'app.database.crud.server_squad.get_available_server_squads',
|
||||
AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(squad_uuid='fi-uuid'),
|
||||
SimpleNamespace(squad_uuid='ru-uuid'),
|
||||
]
|
||||
),
|
||||
)
|
||||
get_server_ids_mock = AsyncMock(return_value=[11, 12])
|
||||
add_user_to_servers_mock = AsyncMock()
|
||||
monkeypatch.setattr('app.database.crud.server_squad.get_server_ids_by_uuids', get_server_ids_mock)
|
||||
monkeypatch.setattr('app.database.crud.server_squad.add_user_to_servers', add_user_to_servers_mock)
|
||||
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
user_id=1,
|
||||
duration_days=14,
|
||||
traffic_limit_gb=100,
|
||||
device_limit=5,
|
||||
)
|
||||
|
||||
assert subscription.connected_squads == ['fi-uuid', 'ru-uuid']
|
||||
db.add.assert_called_once_with(subscription)
|
||||
db.commit.assert_awaited_once()
|
||||
db.refresh.assert_awaited_once_with(subscription)
|
||||
get_server_ids_mock.assert_awaited_once_with(db, ['fi-uuid', 'ru-uuid'])
|
||||
add_user_to_servers_mock.assert_awaited_once_with(db, [11, 12])
|
||||
@@ -2,8 +2,10 @@
|
||||
Tests for PromoCodeService - focus on promo group integration
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from app.database.models import PromoCodeType
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
|
||||
|
||||
@@ -382,3 +384,90 @@ async def test_promocode_data_includes_promo_group_id(
|
||||
assert 'promocode' in result
|
||||
assert 'promo_group_id' in result['promocode']
|
||||
assert result['promocode']['promo_group_id'] == sample_promo_group.id
|
||||
|
||||
|
||||
async def test_activate_trial_promocode_uses_all_available_squads_when_tariff_has_no_restrictions(
|
||||
monkeypatch,
|
||||
):
|
||||
sample_user = SimpleNamespace(
|
||||
id=1,
|
||||
telegram_id=123456789,
|
||||
username='testuser',
|
||||
full_name='Test User',
|
||||
balance_kopeks=0,
|
||||
language='ru',
|
||||
has_had_paid_subscription=False,
|
||||
total_spent_kopeks=0,
|
||||
)
|
||||
mock_db_session = AsyncMock()
|
||||
mock_db_session.commit = AsyncMock()
|
||||
mock_db_session.rollback = AsyncMock()
|
||||
mock_db_session.refresh = AsyncMock()
|
||||
mock_db_session.delete = AsyncMock()
|
||||
|
||||
promocode = SimpleNamespace(
|
||||
id=10,
|
||||
code='KRTN14',
|
||||
type=PromoCodeType.TRIAL_SUBSCRIPTION.value,
|
||||
balance_bonus_kopeks=0,
|
||||
subscription_days=14,
|
||||
tariff_id=7,
|
||||
promo_group_id=None,
|
||||
promo_group=None,
|
||||
first_purchase_only=False,
|
||||
max_uses=20,
|
||||
current_uses=0,
|
||||
is_active=True,
|
||||
is_valid=True,
|
||||
valid_until=None,
|
||||
)
|
||||
trial_tariff = SimpleNamespace(
|
||||
id=7,
|
||||
name='Trial',
|
||||
traffic_limit_gb=100,
|
||||
device_limit=5,
|
||||
allowed_squads=[],
|
||||
trial_duration_days=14,
|
||||
)
|
||||
created_subscription = SimpleNamespace(id=99)
|
||||
|
||||
monkeypatch.setattr('app.services.promocode_service.RemnaWaveService', lambda: SimpleNamespace())
|
||||
create_remnawave_user_mock = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
'app.services.promocode_service.SubscriptionService',
|
||||
lambda: SimpleNamespace(create_remnawave_user=create_remnawave_user_mock),
|
||||
)
|
||||
monkeypatch.setattr('app.services.promocode_service.get_user_by_id', AsyncMock(return_value=sample_user))
|
||||
monkeypatch.setattr('app.services.promocode_service.get_promocode_by_code', AsyncMock(return_value=promocode))
|
||||
monkeypatch.setattr('app.services.promocode_service.check_user_promocode_usage', AsyncMock(return_value=False))
|
||||
monkeypatch.setattr('app.database.crud.promocode.count_user_recent_activations', AsyncMock(return_value=0))
|
||||
monkeypatch.setattr('app.services.promocode_service.get_subscription_by_user_id', AsyncMock(return_value=None))
|
||||
monkeypatch.setattr('app.services.promocode_service.create_promocode_use', AsyncMock(return_value=object()))
|
||||
monkeypatch.setattr('app.database.crud.tariff.get_tariff_by_id', AsyncMock(return_value=trial_tariff))
|
||||
monkeypatch.setattr('app.database.crud.tariff.get_trial_tariff', AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
'app.database.crud.server_squad.get_available_server_squads',
|
||||
AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(squad_uuid='fi-uuid'),
|
||||
SimpleNamespace(squad_uuid='ru-uuid'),
|
||||
]
|
||||
),
|
||||
)
|
||||
create_trial_subscription_mock = AsyncMock(return_value=created_subscription)
|
||||
monkeypatch.setattr('app.database.crud.subscription.create_trial_subscription', create_trial_subscription_mock)
|
||||
|
||||
service = PromoCodeService()
|
||||
result = await service.activate_promocode(mock_db_session, sample_user.id, promocode.code)
|
||||
|
||||
assert result['success'] is True
|
||||
create_trial_subscription_mock.assert_awaited_once_with(
|
||||
mock_db_session,
|
||||
sample_user.id,
|
||||
duration_days=14,
|
||||
traffic_limit_gb=100,
|
||||
device_limit=5,
|
||||
connected_squads=['fi-uuid', 'ru-uuid'],
|
||||
tariff_id=7,
|
||||
)
|
||||
create_remnawave_user_mock.assert_awaited_once_with(mock_db_session, created_subscription)
|
||||
|
||||
Reference in New Issue
Block a user