Compare commits

...

75 Commits

Author SHA1 Message Date
Egor ead0fc99d3 Merge pull request #2886 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.48.0
2026-04-16 06:11:21 +03:00
github-actions[bot] c09ae7436c chore(main): release 3.48.0 2026-04-16 03:10:42 +00:00
Egor 1ea76575ce Merge pull request #2885 from BEDOLAGA-DEV/dev
Dev
2026-04-16 06:10:19 +03:00
Fringg 25447edc9e docs: add SEVERPAY, PAYPEAR, ROLLYPAY to .env.example 2026-04-16 06:08:10 +03:00
Fringg a59858227f fix: support payment_method selection for RollyPay (sbp/card/crypto)
- Mixin accepts payment_method_type parameter (None = show all on form)
- API service sends payment_method only when specified
- Cabinet route passes payment_option to mixin
- Config service has sub_options: sbp, card, crypto
- Keyboard label no longer hardcodes "СБП"
2026-04-16 06:03:33 +03:00
Fringg ccc2f4efec feat: integrate RollyPay payment provider (SBP via USDT)
Full integration following PayPear/SeverPay patterns:
- API client with X-API-Key + X-Nonce auth, HMAC-SHA256 webhook verification
- RollyPayPaymentMixin with create, webhook, finalize (all side effects), status check
- CRUD, model, migration (0059), PaymentMethod.ROLLYPAY enum
- Webhook endpoint, cabinet topup, bot handler
- Statuses: created, processing, paid, expired, canceled, chargeback
- Config: ROLLYPAY_ENABLED, ROLLYPAY_API_KEY, ROLLYPAY_SIGNING_SECRET, etc.
2026-04-16 05:52:25 +03:00
Fringg a18f6caa9b feat: integrate PayPear payment provider
Full integration following SeverPay patterns:
- API client with Basic Auth, idempotency, HMAC webhook verification
- PayPearPaymentMixin with create, webhook, finalize (all side effects), status check
- CRUD, model, migration (0058), PaymentMethod.PAYPEAR enum
- Webhook endpoint, cabinet topup, bot handler, payment verification
- Sub-options: bank_card, sbp, sberpay, tpay
- Config: PAYPEAR_ENABLED, PAYPEAR_SHOP_ID, PAYPEAR_SECRET_KEY, etc.
2026-04-16 05:36:48 +03:00
Fringg 92eaf45311 fix: increase nalogo receipt queue retry window to 12 hours
Changed defaults from 10 attempts × 5min (50min total) to
72 attempts × 10min (12 hours total). When nalog.ru is temporarily
down, receipts now retry for 12 hours before being dropped,
giving the service enough time to recover.
2026-04-16 04:58:54 +03:00
Fringg 61cf495fc5 fix: show menu buttons for limited subscriptions in back-to-menu paths
The previous fix only covered /start command paths. The more common
show_main_menu and handle_back_to_menu in menu.py used their own
is_active check which returned False for limited status.

Now both paths treat limited subscriptions as active for UI, matching
the _calculate_subscription_flags fix in start.py.
2026-04-16 04:54:57 +03:00
Fringg 0c545490b6 fix: show menu buttons for limited (traffic exhausted) subscriptions
When Remnawave webhook set subscription status to 'limited' (traffic
exhausted), the main menu hid ALL buttons (connect, subscription,
buy traffic) because is_active returns False for non-'active' status.

Now 'limited' is treated as active for UI purposes — the subscription
is not expired, just traffic-exhausted. Users can see the "Buy traffic"
button precisely when they need it most.
2026-04-16 04:50:13 +03:00
Fringg 2d5afe5d75 fix: low balance alerts disabled by default, add quiet hours, expiry filter, top-up button
- Default balance_low_enabled changed to False (opt-in via cabinet)
- Quiet hours: alerts skipped between 22:00-09:00 UTC
- Only alerts when subscription expires within LOW_BALANCE_ALERT_EXPIRY_DAYS (default 3)
- Added inline "Top up" button linking to cabinet miniapp
- Synced defaults across notification_prefs, cabinet notifications route
2026-04-16 04:42:36 +03:00
Egor 50dc5a0fd1 Merge pull request #2882 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.47.0
2026-04-15 14:12:24 +03:00
github-actions[bot] 4dc8b4c091 chore(main): release 3.47.0 2026-04-15 11:10:30 +00:00
Egor 4db9e85062 feat: multi-tariff sync fix, daily discount fix, campaign links, TELEGRAM_API_URL
* fix: update subscription_crypto_link when syncing user from panel (#2867)

* fix: update subscription_crypto_link when syncing user from panel

* fix: update subscription_crypto_link when syncing user from panel

* fix: use PROXY_URL for Telegram OIDC JWKS requests (#2866)

* feat: add TELEGRAM_API_URL for custom Telegram Bot API server

Support custom Telegram Bot API server URL via TELEGRAM_API_URL env var.
Enables bot operation in regions where api.telegram.org is blocked
(Cloudflare Worker, self-hosted telegram-bot-api, nginx reverse proxy).
Uses native aiogram TelegramAPIServer.from_base(), works with PROXY_URL.

* fix(ci): read Docker image version from release-please manifest instead of hardcoding (#2859)

The x-release-please-version markers in workflow files were stuck at v3.7.0
since commit 5070bb34 removed them from extra-files (GitHub Actions returns
403 when release-please tries to modify .github/workflows/ via GITHUB_TOKEN).

Instead of hardcoding the version, read it from .release-please-manifest.json
at build time. This file is always kept in sync by release-please and does
not require workflow file write permissions.

---

Маркеры x-release-please-version в workflow-файлах застряли на v3.7.0
после коммита 5070bb34, который удалил их из extra-files (GitHub Actions
возвращает 403 при попытке release-please изменить .github/workflows/
через GITHUB_TOKEN).

Вместо хардкода версии теперь читаем её из .release-please-manifest.json
во время сборки. Этот файл всегда синхронизируется release-please и не
требует прав на запись в workflow-файлы.

* fix: format telegram_auth.py to use single quotes (ruff)

* fix: remove daily tariff fallback to smallest period discount

Daily tariffs (period_days=1) incorrectly inherited the discount
of the smallest configured period (e.g. 90 days -> 5%). This caused
daily prices to show discounts that were never intended for them.

Now daily tariffs only get a discount if explicitly configured for
period_days=1 in the promo group's period_discounts.

* fix: use CABINET_URL for campaign web links instead of MINIAPP_CUSTOM_URL

Campaign web links were generated from MINIAPP_CUSTOM_URL which is often
empty, causing get_campaign_web_link() to return None. Admins and partners
could only share bot links for campaigns, not cabinet links.

Now prefers CABINET_URL (where the auth flow captures ?campaign= param),
falling back to MINIAPP_CUSTOM_URL for backwards compatibility. This is
consistent with how referral web links already use CABINET_URL.

* feat: add DISPLAY_NAME_RESTRICTION_ENABLED toggle

Allows disabling the display name restriction middleware via .env.
Users with special characters in their Telegram name (e.g. "@")
were blocked from using the bot entirely. Default: true (enabled).

Set DISPLAY_NAME_RESTRICTION_ENABLED=false to disable.

* fix: allow clearing all period discounts from promo groups

Empty period_discounts dict was normalized to None by the schema,
making it indistinguishable from "field absent" (don't update).
Now empty dict passes through to CRUD which correctly sets
period_discounts=None in DB, clearing all discounts.

* fix: create panel user instead of update for new subscriptions in multi-tariff mode

In multi-tariff mode, new subscriptions have remnawave_uuid=None.
The old logic fell back to user.remnawave_uuid (from a previous
subscription) and called update_remnawave_user(), which refused
to work because the NEW subscription had no UUID.

Now correctly: in multi-tariff mode, always CREATE if subscription
has no remnawave_uuid. In single-tariff mode, use user-level UUID.

Fixes: "subscription has no remnawave_uuid, cannot update panel"

* fix: apply same create-vs-update fix to renewal and purchase flows

Same bug as the tariff purchase fix: in multi-tariff mode, new
subscriptions without remnawave_uuid incorrectly fell back to
user.remnawave_uuid and called update instead of create.

Fixed in subscription_renewal_service.py and purchase.py to use
the same _should_create pattern based on mode.

* fix: apply create-vs-update fix to all remaining tariff_purchase flows

Fixed 6 more locations in tariff_purchase.py that had the same broken
pattern (custom purchase, daily purchase, trial conversion, tariff
switch, daily switch, instant switch). All now use _should_create
based on multi-tariff mode instead of falling back to user UUID.

* fix: apply create-vs-update fix to cabinet traffic/devices and monitoring

Same multi-tariff create-vs-update bug in 5 more locations:
- cabinet/subscription_modules/traffic.py (2 instances)
- cabinet/subscription_modules/devices.py (2 instances)
- services/monitoring_service.py (1 instance)

All now use _should_create pattern based on subscription.remnawave_uuid
in multi-tariff mode instead of falling back to user.remnawave_uuid.

* fix: ruff format traffic.py and monitoring_service.py

---------

Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com>
Co-authored-by: Gary Jarrel <gary@jarrel.com.au>
2026-04-15 14:10:05 +03:00
Egor fca8d6da97 Dev (#2880)
* fix: update subscription_crypto_link when syncing user from panel (#2867)

* fix: update subscription_crypto_link when syncing user from panel

* fix: update subscription_crypto_link when syncing user from panel

* fix: use PROXY_URL for Telegram OIDC JWKS requests (#2866)

* feat: add TELEGRAM_API_URL for custom Telegram Bot API server

Support custom Telegram Bot API server URL via TELEGRAM_API_URL env var.
Enables bot operation in regions where api.telegram.org is blocked
(Cloudflare Worker, self-hosted telegram-bot-api, nginx reverse proxy).
Uses native aiogram TelegramAPIServer.from_base(), works with PROXY_URL.

* fix(ci): read Docker image version from release-please manifest instead of hardcoding (#2859)

The x-release-please-version markers in workflow files were stuck at v3.7.0
since commit 5070bb34 removed them from extra-files (GitHub Actions returns
403 when release-please tries to modify .github/workflows/ via GITHUB_TOKEN).

Instead of hardcoding the version, read it from .release-please-manifest.json
at build time. This file is always kept in sync by release-please and does
not require workflow file write permissions.

---

Маркеры x-release-please-version в workflow-файлах застряли на v3.7.0
после коммита 5070bb34, который удалил их из extra-files (GitHub Actions
возвращает 403 при попытке release-please изменить .github/workflows/
через GITHUB_TOKEN).

Вместо хардкода версии теперь читаем её из .release-please-manifest.json
во время сборки. Этот файл всегда синхронизируется release-please и не
требует прав на запись в workflow-файлы.

* fix: format telegram_auth.py to use single quotes (ruff)

* fix: remove daily tariff fallback to smallest period discount

Daily tariffs (period_days=1) incorrectly inherited the discount
of the smallest configured period (e.g. 90 days -> 5%). This caused
daily prices to show discounts that were never intended for them.

Now daily tariffs only get a discount if explicitly configured for
period_days=1 in the promo group's period_discounts.

* fix: use CABINET_URL for campaign web links instead of MINIAPP_CUSTOM_URL

Campaign web links were generated from MINIAPP_CUSTOM_URL which is often
empty, causing get_campaign_web_link() to return None. Admins and partners
could only share bot links for campaigns, not cabinet links.

Now prefers CABINET_URL (where the auth flow captures ?campaign= param),
falling back to MINIAPP_CUSTOM_URL for backwards compatibility. This is
consistent with how referral web links already use CABINET_URL.

* feat: add DISPLAY_NAME_RESTRICTION_ENABLED toggle

Allows disabling the display name restriction middleware via .env.
Users with special characters in their Telegram name (e.g. "@")
were blocked from using the bot entirely. Default: true (enabled).

Set DISPLAY_NAME_RESTRICTION_ENABLED=false to disable.

* fix: allow clearing all period discounts from promo groups

Empty period_discounts dict was normalized to None by the schema,
making it indistinguishable from "field absent" (don't update).
Now empty dict passes through to CRUD which correctly sets
period_discounts=None in DB, clearing all discounts.

* fix: create panel user instead of update for new subscriptions in multi-tariff mode

In multi-tariff mode, new subscriptions have remnawave_uuid=None.
The old logic fell back to user.remnawave_uuid (from a previous
subscription) and called update_remnawave_user(), which refused
to work because the NEW subscription had no UUID.

Now correctly: in multi-tariff mode, always CREATE if subscription
has no remnawave_uuid. In single-tariff mode, use user-level UUID.

Fixes: "subscription has no remnawave_uuid, cannot update panel"

* fix: apply same create-vs-update fix to renewal and purchase flows

Same bug as the tariff purchase fix: in multi-tariff mode, new
subscriptions without remnawave_uuid incorrectly fell back to
user.remnawave_uuid and called update instead of create.

Fixed in subscription_renewal_service.py and purchase.py to use
the same _should_create pattern based on mode.

* fix: apply create-vs-update fix to all remaining tariff_purchase flows

Fixed 6 more locations in tariff_purchase.py that had the same broken
pattern (custom purchase, daily purchase, trial conversion, tariff
switch, daily switch, instant switch). All now use _should_create
based on multi-tariff mode instead of falling back to user UUID.

* fix: apply create-vs-update fix to cabinet traffic/devices and monitoring

Same multi-tariff create-vs-update bug in 5 more locations:
- cabinet/subscription_modules/traffic.py (2 instances)
- cabinet/subscription_modules/devices.py (2 instances)
- services/monitoring_service.py (1 instance)

All now use _should_create pattern based on subscription.remnawave_uuid
in multi-tariff mode instead of falling back to user.remnawave_uuid.

* fix: ruff format traffic.py and monitoring_service.py

---------

Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com>
Co-authored-by: Gary Jarrel <gary@jarrel.com.au>
2026-04-15 14:04:16 +03:00
Fringg 4fe67a9c74 fix: ruff format traffic.py and monitoring_service.py 2026-04-15 13:57:15 +03:00
Fringg 30a1a31978 fix: apply create-vs-update fix to cabinet traffic/devices and monitoring
Same multi-tariff create-vs-update bug in 5 more locations:
- cabinet/subscription_modules/traffic.py (2 instances)
- cabinet/subscription_modules/devices.py (2 instances)
- services/monitoring_service.py (1 instance)

All now use _should_create pattern based on subscription.remnawave_uuid
in multi-tariff mode instead of falling back to user.remnawave_uuid.
2026-04-15 13:28:06 +03:00
Fringg c2b68e1afa fix: apply create-vs-update fix to all remaining tariff_purchase flows
Fixed 6 more locations in tariff_purchase.py that had the same broken
pattern (custom purchase, daily purchase, trial conversion, tariff
switch, daily switch, instant switch). All now use _should_create
based on multi-tariff mode instead of falling back to user UUID.
2026-04-15 13:24:07 +03:00
Fringg cea7260a85 fix: apply same create-vs-update fix to renewal and purchase flows
Same bug as the tariff purchase fix: in multi-tariff mode, new
subscriptions without remnawave_uuid incorrectly fell back to
user.remnawave_uuid and called update instead of create.

Fixed in subscription_renewal_service.py and purchase.py to use
the same _should_create pattern based on mode.
2026-04-15 13:17:35 +03:00
Fringg e3c0caabcf fix: create panel user instead of update for new subscriptions in multi-tariff mode
In multi-tariff mode, new subscriptions have remnawave_uuid=None.
The old logic fell back to user.remnawave_uuid (from a previous
subscription) and called update_remnawave_user(), which refused
to work because the NEW subscription had no UUID.

Now correctly: in multi-tariff mode, always CREATE if subscription
has no remnawave_uuid. In single-tariff mode, use user-level UUID.

Fixes: "subscription has no remnawave_uuid, cannot update panel"
2026-04-15 13:14:10 +03:00
Fringg aeaa4f8e0d fix: allow clearing all period discounts from promo groups
Empty period_discounts dict was normalized to None by the schema,
making it indistinguishable from "field absent" (don't update).
Now empty dict passes through to CRUD which correctly sets
period_discounts=None in DB, clearing all discounts.
2026-04-15 04:13:43 +03:00
Fringg e226ac8637 feat: add DISPLAY_NAME_RESTRICTION_ENABLED toggle
Allows disabling the display name restriction middleware via .env.
Users with special characters in their Telegram name (e.g. "@")
were blocked from using the bot entirely. Default: true (enabled).

Set DISPLAY_NAME_RESTRICTION_ENABLED=false to disable.
2026-04-15 04:03:18 +03:00
Fringg 85403da528 fix: use CABINET_URL for campaign web links instead of MINIAPP_CUSTOM_URL
Campaign web links were generated from MINIAPP_CUSTOM_URL which is often
empty, causing get_campaign_web_link() to return None. Admins and partners
could only share bot links for campaigns, not cabinet links.

Now prefers CABINET_URL (where the auth flow captures ?campaign= param),
falling back to MINIAPP_CUSTOM_URL for backwards compatibility. This is
consistent with how referral web links already use CABINET_URL.
2026-04-15 03:56:34 +03:00
Fringg bdd873382c fix: remove daily tariff fallback to smallest period discount
Daily tariffs (period_days=1) incorrectly inherited the discount
of the smallest configured period (e.g. 90 days -> 5%). This caused
daily prices to show discounts that were never intended for them.

Now daily tariffs only get a discount if explicitly configured for
period_days=1 in the promo group's period_discounts.
2026-04-15 03:39:52 +03:00
Fringg 9d750d9eb0 fix: format telegram_auth.py to use single quotes (ruff) 2026-04-15 03:34:49 +03:00
Gary Jarrel 87b83f59c6 fix(ci): read Docker image version from release-please manifest instead of hardcoding (#2859)
The x-release-please-version markers in workflow files were stuck at v3.7.0
since commit 5070bb34 removed them from extra-files (GitHub Actions returns
403 when release-please tries to modify .github/workflows/ via GITHUB_TOKEN).

Instead of hardcoding the version, read it from .release-please-manifest.json
at build time. This file is always kept in sync by release-please and does
not require workflow file write permissions.

---

Маркеры x-release-please-version в workflow-файлах застряли на v3.7.0
после коммита 5070bb34, который удалил их из extra-files (GitHub Actions
возвращает 403 при попытке release-please изменить .github/workflows/
через GITHUB_TOKEN).

Вместо хардкода версии теперь читаем её из .release-please-manifest.json
во время сборки. Этот файл всегда синхронизируется release-please и не
требует прав на запись в workflow-файлы.
2026-04-15 03:15:35 +03:00
Fringg 8ff6f99229 feat: add TELEGRAM_API_URL for custom Telegram Bot API server
Support custom Telegram Bot API server URL via TELEGRAM_API_URL env var.
Enables bot operation in regions where api.telegram.org is blocked
(Cloudflare Worker, self-hosted telegram-bot-api, nginx reverse proxy).
Uses native aiogram TelegramAPIServer.from_base(), works with PROXY_URL.
2026-04-15 03:12:59 +03:00
Dmitry V. Lunin 94da3c35b0 fix: use PROXY_URL for Telegram OIDC JWKS requests (#2866) 2026-04-15 03:08:06 +03:00
Dmitry V. Lunin d35a8bb74c fix: update subscription_crypto_link when syncing user from panel (#2867)
* fix: update subscription_crypto_link when syncing user from panel

* fix: update subscription_crypto_link when syncing user from panel
2026-04-15 03:07:07 +03:00
Egor c635d88764 Merge pull request #2878 from BEDOLAGA-DEV/main
w
2026-04-15 02:53:04 +03:00
c0mrade 5009703676 Merge pull request #2875 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.46.1
2026-04-13 22:09:58 +03:00
github-actions[bot] e88a5989b6 chore(main): release 3.46.1 2026-04-13 19:07:27 +00:00
c0mrade 02747381dc Merge pull request #2874 from BEDOLAGA-DEV/dev
fix: cabinet_refresh_tokens migration + notification_settings jsonb
2026-04-13 22:06:53 +03:00
c0mrade e74fda954c fix: change notification_settings from json to jsonb for DISTINCT compatibility 2026-04-13 21:56:04 +03:00
c0mrade 8587f03f67 fix: add checkfirst guards to cabinet_refresh_tokens migration 2026-04-13 21:47:16 +03:00
c0mrade 4707cdf60c fix: add missing migration for cabinet_refresh_tokens table 2026-04-13 21:43:46 +03:00
c0mrade 0879b8b218 Merge pull request #2873 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.46.0
2026-04-13 19:47:36 +03:00
github-actions[bot] 1d91382b8e chore(main): release 3.46.0 2026-04-13 16:37:54 +00:00
c0mrade 3768b18a39 Merge pull request #2872 from BEDOLAGA-DEV/dev
Bugfixes: campaign, tickets, NaloGO, devices, broadcasts, menu editor
2026-04-13 19:37:16 +03:00
c0mrade 1eeeb39779 fix: exclude users with active subscriptions from expired broadcast
In multi-subscription mode, a user with an expired trial AND an active
paid subscription was incorrectly included in expired broadcast targets.

Fixed in 3 places:
- get_target_users_count (SQL): added NOT EXISTS active sub subquery
- get_target_users 'expired' (Python): skip if has_active
- get_target_users 'expired_subscribers' (Python): same check
2026-04-13 19:21:52 +03:00
c0mrade 570af82dfd fix: raise MAX_BUTTONS_PER_ROW to 8 and allow tg:// deep links in menu editor
MAX_BUTTONS_PER_ROW was 3, causing Pydantic 422 when adding multiple
custom buttons to a row. Telegram allows up to 8 buttons per row.

Also added tg:// to URL_PATTERN so admins can use Telegram deep links
(tg://resolve, tg://user, etc.) in custom menu buttons. webapp mode
still requires https:// as enforced by existing validation.
2026-04-13 17:07:19 +03:00
c0mrade bc3893b934 fix: use MAX_DEVICES_LIMIT instead of hardcoded 10 for device buttons
Admin user device editor had hardcoded limit of 10 in text, validation,
and inline buttons. Now uses settings.MAX_DEVICES_LIMIT dynamically,
generating buttons 1..N in rows of 4. Falls back to text-only input
if limit exceeds Telegram's 100-button cap.
2026-04-13 14:42:16 +03:00
c0mrade 16d91638bc fix: enforce max_attempts limit in NaloGO receipt queue
The _max_attempts property existed but was never checked as a limit.
Receipts were retried indefinitely (55+ attempts observed in logs).
Now receipts exceeding max_attempts are removed from the queue.
2026-04-13 14:42:06 +03:00
c0mrade eb18b3a0f9 fix: handle TelegramBadRequest when deleting old ticket notifications
Messages older than 48 hours cannot be deleted via Telegram API.
Now falls back to editing the message text instead of crashing.
2026-04-13 14:41:59 +03:00
c0mrade a8e2b62f4b feat: save campaign_slug during standalone email registration
Campaign slug was lost during email registration flow — it was only
sent at verification time from localStorage, which is empty if the
user opens the verification link in a different browser/webview.

Now campaign_slug is accepted in the registration request, saved to
user.pending_campaign_slug, and used as fallback during email
verification. Also processed immediately for auto-verified test emails.
2026-04-13 14:41:49 +03:00
c0mrade fb8d2b3ee4 fix: upsert refresh tokens (ON CONFLICT) + periodic cleanup of expired/revoked tokens 2026-04-10 18:08:27 +03:00
c0mrade 2321667ecb fix: add TRAFFIC_WARNING_ALERT and LOW_BALANCE_ALERT localization keys to all locales 2026-04-10 17:58:04 +03:00
c0mrade 113304b212 style: remove unused import (ruff fix) 2026-04-10 16:47:41 +03:00
c0mrade 0300044b00 feat: add category field to broadcast API schemas and routes 2026-04-10 16:10:13 +03:00
c0mrade 931abfe7a5 feat: add broadcast category (system/news/promo) + filter recipients by user prefs 2026-04-10 16:05:43 +03:00
c0mrade 1d96f80f60 feat: add traffic % warning check using user's threshold preference 2026-04-10 15:59:32 +03:00
c0mrade 4e50419171 feat: implement low balance alert + respect user notification preferences
- Add _check_low_balance_alerts to monitoring service
- Notify users with autopay when balance drops below their threshold
- Uses notification_prefs helper for per-user settings
2026-04-10 15:43:32 +03:00
c0mrade 7208a52c94 feat: respect user traffic_warning notification preference in webhook handler 2026-04-10 15:37:33 +03:00
c0mrade 63fdfe4a42 feat: respect user subscription_expiry notification preferences 2026-04-10 15:37:00 +03:00
c0mrade e0e2edf816 feat: add user notification preferences helper utility 2026-04-10 15:35:30 +03:00
c0mrade 522a8779d6 style: fix ruff format for all sync-related changes 2026-04-10 15:13:59 +03:00
c0mrade be32010d63 fix: trial activation fallback to trial-eligible servers when tariff has no squads (BUG-12) + fix misleading button text 2026-04-10 15:04:38 +03:00
c0mrade 7e920fa30f fix: add retry queue to all remaining RemnaWave error handlers 2026-04-10 14:22:10 +03:00
c0mrade 1b376baeca fix: add retry queue to cabinet subscription operation RemnaWave errors 2026-04-10 11:56:48 +03:00
c0mrade 91a756a33e fix: add retry queue to payment webhook and renewal service RemnaWave errors 2026-04-10 11:56:24 +03:00
c0mrade 970dc549df fix: add retry queue to classic mode bot purchase handler 2026-04-10 11:40:20 +03:00
c0mrade 65120f0bad fix: add retry queue to daily subscription service RemnaWave errors 2026-04-10 11:39:39 +03:00
c0mrade 9cb559ff39 fix: enqueue retry on RemnaWave API failure in all purchase flows (BUG-2, BUG-10)
Add remnawave_retry_queue.enqueue() calls in all 10 purchase error handlers
where RemnaWave API failure was caught and swallowed without scheduling a retry:
- cabinet purchase.py: purchase_tariff() and activate_trial() (2 places)
- subscription_purchase_service.py: miniapp purchase flow (1 place)
- tariff_purchase.py: custom, standard, daily, renewal, switch, daily-switch,
  and instant-switch flows (7 places)
2026-04-10 11:04:02 +03:00
c0mrade 8f1882f24c feat: start RemnaWave retry queue on app startup 2026-04-10 10:48:14 +03:00
c0mrade 8542a39305 fix: always sync squads in auto-purchase renewal (BUG-4) 2026-04-10 10:48:01 +03:00
c0mrade 646ac4cfa1 fix: match tariff_id when creating subscriptions from panel sync (BUG-11) 2026-04-10 10:41:50 +03:00
c0mrade abdf296767 feat: add RemnaWave retry queue for failed API calls (BUG-2, BUG-10) 2026-04-10 10:41:49 +03:00
c0mrade a1b6d9bb61 fix: use update_remnawave_user when UUID exists in tariff_purchase (BUG-3) 2026-04-10 10:38:00 +03:00
c0mrade cf19e4e1f7 fix: protect OAuth users with remnawave_uuid from sync deactivation (BUG-6) 2026-04-10 10:36:16 +03:00
c0mrade 35412e9f21 fix: sync connected_squads from panel during sync (BUG-5) 2026-04-10 10:36:02 +03:00
c0mrade 6aed7d355b fix: default sync_squads=True in update_remnawave_user (BUG-4) 2026-04-10 10:34:56 +03:00
c0mrade 9c08ce6948 fix: resync RemnaWave after account merge (BUG-7) 2026-04-10 10:33:45 +03:00
c0mrade 862352139e fix: use 'is not None' for telegram_id in create_user API (BUG-9) 2026-04-10 10:33:25 +03:00
c0mrade d465ccb3ac fix: resync RemnaWave after Telegram account linking (BUG-1) 2026-04-10 10:32:39 +03:00
c0mrade b57f185258 feat: add remnawave_resync_service for identity-change sync
Introduces resync_user_subscriptions_with_panel(), a standalone async
helper that re-pushes all active subscriptions to the RemnaWave panel
after any identity change (TG linking, account merge, email verification).
Handles multi-tariff vs. single-tariff mode, create vs. update branching,
tariff eager-loading, and returns a synced/failed/total stats dict.
2026-04-10 10:31:12 +03:00
90 changed files with 9876 additions and 5189 deletions
+51 -1
View File
@@ -13,11 +13,15 @@ SUPPORT_USERNAME=@support
# Имя пользователя бота (опционально, автоопределяется)
# BOT_USERNAME=
# ===== SOCKS5 ПРОКСИ =====
# ===== СЕТЬ И ПРОКСИ =====
# URL SOCKS5 прокси-сервера для маршрутизации трафика бота к Telegram API
# Формат: socks5://user:password@host:port или socks5://host:port
# PROXY_URL=socks5://127.0.0.1:1080
# Альтернативный URL сервера Telegram Bot API (для регионов где api.telegram.org заблокирован)
# Примеры: Cloudflare Worker, self-hosted telegram-bot-api (tdlib), любой совместимый прокси
# TELEGRAM_API_URL=https://your-telegram-proxy.workers.dev
# ===== СИСТЕМА ПОДДЕРЖКИ =====
# Включить меню поддержки в интерфейсе
SUPPORT_MENU_ENABLED=true
@@ -680,6 +684,52 @@ RIOPAY_WEBHOOK_PATH=/riopay-webhook
RIOPAY_SUCCESS_URL=
RIOPAY_FAIL_URL=
# ===== SEVERPAY (severpay.io) =====
SEVERPAY_ENABLED=false
# Merchant ID
SEVERPAY_MID=
# Секретный токен для HMAC-SHA256
SEVERPAY_TOKEN=
SEVERPAY_DISPLAY_NAME=SeverPay
SEVERPAY_CURRENCY=RUB
SEVERPAY_MIN_AMOUNT_KOPEKS=10000
SEVERPAY_MAX_AMOUNT_KOPEKS=10000000
SEVERPAY_WEBHOOK_PATH=/severpay-webhook
# URL возврата после оплаты
# SEVERPAY_RETURN_URL=
# Время жизни платежа в минутах (30-4320)
SEVERPAY_LIFETIME=1440
# ===== PAYPEAR (api.paypear.ru) =====
PAYPEAR_ENABLED=false
# Shop ID для HTTP Basic Auth
PAYPEAR_SHOP_ID=
# Secret Key для HTTP Basic Auth
PAYPEAR_SECRET_KEY=
PAYPEAR_DISPLAY_NAME=PayPear
PAYPEAR_CURRENCY=RUB
PAYPEAR_MIN_AMOUNT_KOPEKS=10000
PAYPEAR_MAX_AMOUNT_KOPEKS=10000000
PAYPEAR_WEBHOOK_PATH=/paypear-webhook
# URL возврата после оплаты
# PAYPEAR_RETURN_URL=
# Время жизни платежа в минутах
PAYPEAR_PAYMENT_LIFETIME_MINUTES=60
# ===== ROLLYPAY (rollypay.io) =====
ROLLYPAY_ENABLED=false
# API ключ (X-API-Key header)
ROLLYPAY_API_KEY=
# Секрет для HMAC-SHA256 верификации вебхуков
ROLLYPAY_SIGNING_SECRET=
ROLLYPAY_DISPLAY_NAME=RollyPay
ROLLYPAY_CURRENCY=RUB
ROLLYPAY_MIN_AMOUNT_KOPEKS=10000
ROLLYPAY_MAX_AMOUNT_KOPEKS=10000000
ROLLYPAY_WEBHOOK_PATH=/rollypay-webhook
# URL возврата после оплаты
# ROLLYPAY_RETURN_URL=
# ===== WATA =====
WATA_ENABLED=false
WATA_BASE_URL=https://api.wata.pro
Binary file not shown.

After

Width:  |  Height:  |  Size: 822 KiB

+13 -11
View File
@@ -26,36 +26,38 @@ jobs:
- name: Get version info
id: version
run: |
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
SHORT_SHA=$(git rev-parse --short HEAD)
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
# Определяем версию и теги
# Read base version from release-please manifest (single source of truth)
BASE_VERSION=$(jq -r '."."' .release-please-manifest.json)
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v${BASE_VERSION}-${SHORT_SHA}"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v${BASE_VERSION}-dev-${SHORT_SHA}"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
VERSION="v${BASE_VERSION}-pr-${SHORT_SHA}"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-${SHORT_SHA}"
echo "🔀 Собираем PR версию: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tags=$TAGS" >> $GITHUB_OUTPUT
echo "should_push=${{ github.event_name != 'pull_request' }}" >> $GITHUB_OUTPUT
echo "=== Информация о сборке ==="
echo "Версия: $VERSION"
echo "Коммит: $(git rev-parse --short HEAD)"
echo "Коммит: $SHORT_SHA"
echo "Теги: $TAGS"
echo "Push: ${{ github.event_name != 'pull_request' }}"
echo "==========================="
+10 -7
View File
@@ -42,25 +42,28 @@ jobs:
- name: Get version info
id: version
run: |
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
SHORT_SHA=$(git rev-parse --short HEAD)
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
# Read base version from release-please manifest (single source of truth)
BASE_VERSION=$(jq -r '."."' .release-please-manifest.json)
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v${BASE_VERSION}-${SHORT_SHA}"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v${BASE_VERSION}-dev-${SHORT_SHA}"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v${BASE_VERSION}-pr-${SHORT_SHA}"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Определяем, нужно ли пушить образ
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "should_push=false" >> $GITHUB_OUTPUT
echo "⚠️ PR - only build without push"
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.45.2"
".": "3.48.0"
}
+83
View File
@@ -1,5 +1,88 @@
# Changelog
## [3.48.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.47.0...v3.48.0) (2026-04-16)
### New Features
* integrate PayPear payment provider ([a18f6ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a18f6caa9bd9c08511c464e6141fb8aa614135b0))
* integrate RollyPay payment provider (SBP via USDT) ([ccc2f4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ccc2f4efecf0a3c1a943971c81a9a7d1985ae14a))
### Bug Fixes
* increase nalogo receipt queue retry window to 12 hours ([92eaf45](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/92eaf4531162e5625b938bafc43eb98abe2632e2))
* low balance alerts disabled by default, add quiet hours, expiry filter, top-up button ([2d5afe5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2d5afe5d75ff65f4d167a853e078943d518a881f))
* show menu buttons for limited (traffic exhausted) subscriptions ([0c54549](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c545490b61baec930f10adc86652a7e5cf5d378))
* show menu buttons for limited subscriptions in back-to-menu paths ([61cf495](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61cf495fc5919e699aba7231f49222722da044b4))
* support payment_method selection for RollyPay (sbp/card/crypto) ([a598582](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a59858227f31bd53d0ac54d693288ad52e447687))
### Documentation
* add SEVERPAY, PAYPEAR, ROLLYPAY to .env.example ([25447ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25447edc9eab7c3a76ed94be52c1b341917a40c2))
## [3.47.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.46.1...v3.47.0) (2026-04-15)
### New Features
* multi-tariff sync fix, daily discount fix, campaign links, TELEGRAM_API_URL ([4db9e85](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4db9e850629f25cbcb11bb5ba0e0de5c580ca115))
## [3.46.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.46.0...v3.46.1) (2026-04-13)
### Bug Fixes
* add checkfirst guards to cabinet_refresh_tokens migration ([8587f03](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8587f03f67d7a451b07a4d9c450bc4524f4ac0e7))
* add missing migration for cabinet_refresh_tokens table ([4707cdf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4707cdf60c9d163c1191719b3b1fc4a17ae993d2))
* cabinet_refresh_tokens migration + notification_settings jsonb ([0274738](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02747381dce2b96af7f97b5f41d6acff5d9d8fd3))
* change notification_settings from json to jsonb for DISTINCT compatibility ([e74fda9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e74fda954ccc63e2ac7a30933d9f9ac26772b25d))
## [3.46.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.2...v3.46.0) (2026-04-13)
### New Features
* add broadcast category (system/news/promo) + filter recipients by user prefs ([931abfe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/931abfe7a5a7fb70e9638fbf6b566fa8d1a837e4))
* add category field to broadcast API schemas and routes ([0300044](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0300044b009f3e4b3aa3928652dfaf261a387dbc))
* add RemnaWave retry queue for failed API calls (BUG-2, BUG-10) ([abdf296](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/abdf2967675975e90f0c4d834f129281c1c28e7b))
* add remnawave_resync_service for identity-change sync ([b57f185](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b57f185258be050d945cec5989ad6dc710980a6a))
* add traffic % warning check using user's threshold preference ([1d96f80](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d96f80f60ca445eb5108e7bc54e00d022a4cc9e))
* add user notification preferences helper utility ([e0e2edf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e0e2edf81659fbeea361046d1bb2718c2149d884))
* implement low balance alert + respect user notification preferences ([4e50419](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e50419171176ee452371ff095bdfead3879e554))
* respect user subscription_expiry notification preferences ([63fdfe4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/63fdfe4a421942b26caca35d8bd9b1d65f1fe7e2))
* respect user traffic_warning notification preference in webhook handler ([7208a52](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7208a52c9424d39757187fc86eec2c3460a2cdbb))
* save campaign_slug during standalone email registration ([a8e2b62](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a8e2b62f4bb0833ca32446b34ad4e0c5615fcd2a))
* start RemnaWave retry queue on app startup ([8f1882f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f1882f24c7d066e2d0fc756f38ce23bf082687e))
### Bug Fixes
* add retry queue to all remaining RemnaWave error handlers ([7e920fa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7e920fa30fc8e61ed2dd31e7a09151d57b7361ca))
* add retry queue to cabinet subscription operation RemnaWave errors ([1b376ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b376baeca120970b1ffcd406b1bbf7d9d43cee0))
* add retry queue to classic mode bot purchase handler ([970dc54](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/970dc549dfa06ba9945d5bd861374058acdca86b))
* add retry queue to daily subscription service RemnaWave errors ([65120f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/65120f0badc9a4581ba0a127acdae8a0b23e8501))
* add retry queue to payment webhook and renewal service RemnaWave errors ([91a756a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91a756a33ed4ce685bdf485cdb4e91c3e08799dd))
* add TRAFFIC_WARNING_ALERT and LOW_BALANCE_ALERT localization keys to all locales ([2321667](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2321667ecbe76bfe8dd37213e0bb4e45104e0fc5))
* always sync squads in auto-purchase renewal (BUG-4) ([8542a39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8542a393055a93d320d5c8c6d3aa7cc291cf8def))
* default sync_squads=True in update_remnawave_user (BUG-4) ([6aed7d3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6aed7d355bc47c4dbd2aa761d78a5e5421c32edf))
* enforce max_attempts limit in NaloGO receipt queue ([16d9163](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/16d91638bc149c5eee8f4cdd266cbd195c411030))
* enqueue retry on RemnaWave API failure in all purchase flows (BUG-2, BUG-10) ([9cb559f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9cb559ff3994c0f1c6ba48a4ec09dec9b391e48b))
* exclude users with active subscriptions from expired broadcast ([1eeeb39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1eeeb39779982ebb2700cb7523760631229407da))
* handle TelegramBadRequest when deleting old ticket notifications ([eb18b3a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb18b3a0f9ac3a617a1b21d3293e1619980a2a71))
* match tariff_id when creating subscriptions from panel sync (BUG-11) ([646ac4c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/646ac4cfa18f738040fbc6498c5d86c1546e2b9a))
* protect OAuth users with remnawave_uuid from sync deactivation (BUG-6) ([cf19e4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf19e4e1f7148b21d9a8072f7a0b4ae97fd04e8a))
* raise MAX_BUTTONS_PER_ROW to 8 and allow tg:// deep links in menu editor ([570af82](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/570af82dfdec980f42be96c8f816e1677f896f81))
* resync RemnaWave after account merge (BUG-7) ([9c08ce6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9c08ce69485b78f8fe818500e05ab6995115166a))
* resync RemnaWave after Telegram account linking (BUG-1) ([d465ccb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d465ccb3ac3a86add6313d6bb61d53d0fe143d5e))
* sync connected_squads from panel during sync (BUG-5) ([35412e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/35412e9f215680c0fdf1c55b5bf23496f662935c))
* trial activation fallback to trial-eligible servers when tariff has no squads (BUG-12) + fix misleading button text ([be32010](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be32010d63966498bc6216823a75d328346d9a37))
* upsert refresh tokens (ON CONFLICT) + periodic cleanup of expired/revoked tokens ([fb8d2b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fb8d2b3ee4566823840100b96fe2f3bc7d41edb7))
* use 'is not None' for telegram_id in create_user API (BUG-9) ([8623521](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/862352139e8a3545e144b0618fed5011470a9a67))
* use MAX_DEVICES_LIMIT instead of hardcoded 10 for device buttons ([bc3893b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc3893b934f0d4e5059eedbd03cdfbc628852ac1))
* use update_remnawave_user when UUID exists in tariff_purchase (BUG-3) ([a1b6d9b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1b6d9bb619ec3de038647fe6f2e5d38979298a8))
## [3.45.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.1...v3.45.2) (2026-04-08)
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.45.2" # x-release-please-version
ARG VERSION="v3.48.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+16
View File
@@ -280,12 +280,28 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
except Exception as e:
logger.warning('Failed to load menu layout cache', error=e)
try:
from app.services.remnawave_retry_queue import remnawave_retry_queue
await remnawave_retry_queue.start()
logger.info('RemnaWave retry queue запущен')
except Exception as e:
logger.error('Ошибка запуска RemnaWave retry queue', error=e)
logger.info('Бот успешно настроен')
return bot, dp
async def shutdown_bot():
try:
from app.services.remnawave_retry_queue import remnawave_retry_queue
await remnawave_retry_queue.stop()
logger.info('RemnaWave retry queue остановлен')
except Exception as e:
logger.error('Ошибка остановки RemnaWave retry queue', error=e)
try:
await maintenance_service.stop_monitoring()
logger.info('Мониторинг техработ остановлен')
+12 -4
View File
@@ -1,4 +1,4 @@
"""Factory for creating Bot instances with proxy support."""
"""Factory for creating Bot instances with proxy and custom API server support."""
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
@@ -8,13 +8,21 @@ from app.config import settings
def create_bot(token: str | None = None, **kwargs) -> Bot:
"""Create a Bot instance with SOCKS5 proxy session if PROXY_URL is configured."""
"""Create a Bot instance with SOCKS5 proxy and/or custom Telegram API server."""
proxy_url = settings.get_proxy_url()
telegram_api_url = settings.get_telegram_api_url()
session = None
if proxy_url:
if proxy_url or telegram_api_url:
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
session = AiohttpSession(proxy=proxy_url)
session_kwargs: dict = {}
if proxy_url:
session_kwargs['proxy'] = proxy_url
if telegram_api_url:
session_kwargs['api'] = TelegramAPIServer.from_base(telegram_api_url)
session = AiohttpSession(**session_kwargs)
kwargs.setdefault('default', DefaultBotProperties(parse_mode=ParseMode.HTML))
return Bot(token=token or settings.BOT_TOKEN, session=session, **kwargs)
+2 -1
View File
@@ -195,7 +195,8 @@ async def _get_jwks(force: bool = False) -> dict[str, Any]:
if not force and _jwks_cache and _jwks_cache_expiry and now < _jwks_cache_expiry:
return _jwks_cache
async with httpx.AsyncClient(timeout=10) as client:
proxy = settings.PROXY_URL if hasattr(settings, 'PROXY_URL') and settings.PROXY_URL else None
async with httpx.AsyncClient(timeout=10, proxy=proxy) as client:
response = await client.get(_JWKS_URL)
response.raise_for_status()
_jwks_cache = response.json()
+37
View File
@@ -622,6 +622,24 @@ async def link_telegram(
telegram_id=telegram_id,
user_id=user.id,
)
# BUG-1 fix: Sync all subscriptions with RemnaWave panel so it knows the new telegram_id
try:
from app.services.remnawave_resync_service import resync_user_subscriptions_with_panel
resync_result = await resync_user_subscriptions_with_panel(db, user)
logger.info(
'Post-TG-link resync completed',
user_id=user.id,
telegram_id=telegram_id,
synced=resync_result['synced'],
failed=resync_result['failed'],
)
except Exception as resync_error:
logger.error(
'Post-TG-link resync failed (non-fatal)',
user_id=user.id,
error=resync_error,
)
return LinkCallbackResponse(success=True, message='linked')
@@ -867,6 +885,25 @@ async def execute_merge_endpoint(
detail='Failed to load merged user',
)
# BUG-7 fix: Resync merged user's subscriptions with RemnaWave panel
try:
from app.services.remnawave_resync_service import resync_user_subscriptions_with_panel
resync_result = await resync_user_subscriptions_with_panel(db, merged_user)
logger.info(
'Post-merge resync completed',
primary_user_id=primary_user_id,
secondary_user_id=secondary_user_id,
synced=resync_result['synced'],
failed=resync_result['failed'],
)
except Exception as resync_error:
logger.error(
'Post-merge resync failed (non-fatal)',
primary_user_id=primary_user_id,
error=resync_error,
)
# 5. Create auth tokens for the merged user
try:
auth_response = await _create_auth_response(merged_user, db)
+5
View File
@@ -141,6 +141,7 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
created_at=broadcast.created_at,
completed_at=broadcast.completed_at,
progress_percent=progress,
category=getattr(broadcast, 'category', 'system') or 'system',
channel=getattr(broadcast, 'channel', 'telegram') or 'telegram',
email_subject=getattr(broadcast, 'email_subject', None),
email_html_content=getattr(broadcast, 'email_html_content', None),
@@ -432,6 +433,7 @@ async def create_broadcast(
status='queued',
admin_id=admin.id,
admin_name=admin.username or f'Admin #{admin.id}',
category=request.category,
)
db.add(broadcast)
await db.commit()
@@ -454,6 +456,7 @@ async def create_broadcast(
media=media_config,
initiator_name=admin.username or f'Admin #{admin.id}',
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
category=request.category,
)
# Start broadcast
@@ -626,6 +629,7 @@ async def create_combined_broadcast(
status='queued',
admin_id=admin.id,
admin_name=admin_name,
category=request.category,
channel=request.channel,
email_subject=request.email_subject.strip() if request.email_subject else None,
email_html_content=request.email_html_content.strip() if request.email_html_content else None,
@@ -653,6 +657,7 @@ async def create_combined_broadcast(
media=media_config,
initiator_name=admin_name,
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
category=request.category,
)
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
+3 -3
View File
@@ -42,9 +42,9 @@ router = APIRouter(prefix='/admin/menu-layout', tags=['Admin Menu Layout'])
# ---- Constants ---------------------------------------------------------------
MAX_ROWS = 20
MAX_BUTTONS_PER_ROW = 3
MAX_BUTTONS_PER_ROW = 8 # Telegram inline keyboard limit
MAX_LABEL_LENGTH = 100
URL_PATTERN = re.compile(r'^https?://')
URL_PATTERN = re.compile(r'^(https?://|tg://)')
# ---- Schemas -----------------------------------------------------------------
@@ -275,7 +275,7 @@ def _validate_update_payload(rows: list[RowConfig]) -> None:
if not btn.url or not URL_PATTERN.match(btn.url):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Custom button "{btn.id}" must have a URL starting with http:// or https://.',
detail=f'Custom button "{btn.id}" must have a URL starting with http://, https://, or tg://.',
)
if btn.open_in == 'webapp' and not btn.url.startswith('https://'):
raise HTTPException(
+5
View File
@@ -3023,6 +3023,11 @@ async def sync_user_from_panel(
changes['remnawave_short_uuid'] = {'old': sub.remnawave_short_uuid, 'new': panel_user.short_uuid}
sub.remnawave_short_uuid = panel_user.short_uuid
# Update crypto link
if panel_user.happ_crypto_link and sub.subscription_crypto_link != panel_user.happ_crypto_link:
changes['subscription_crypto_link'] = {'old': sub.subscription_crypto_link, 'new': '***'}
sub.subscription_crypto_link = panel_user.happ_crypto_link
# Update traffic usage if requested
if request.update_traffic and sync_sub:
panel_traffic_used = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes else 0
+29 -10
View File
@@ -144,22 +144,28 @@ async def _store_refresh_token(
refresh_token: str,
device_info: str | None = None,
) -> None:
"""Store refresh token hash in database."""
"""Store refresh token hash in database using upsert to avoid duplicate key errors."""
from sqlalchemy.dialects.postgresql import insert as pg_insert
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
expires_at = get_refresh_token_expires_at()
token_record = CabinetRefreshToken(
stmt = pg_insert(CabinetRefreshToken).values(
user_id=user_id,
token_hash=token_hash,
device_info=device_info,
expires_at=expires_at,
)
db.add(token_record)
try:
await db.commit()
except IntegrityError:
await db.rollback()
logger.debug('Refresh token already exists (duplicate)', user_id=user_id)
stmt = stmt.on_conflict_do_update(
index_elements=['token_hash'],
set_={
'expires_at': expires_at,
'device_info': device_info,
'revoked_at': None,
},
)
await db.execute(stmt)
await db.commit()
async def _process_campaign_bonus(
@@ -1052,6 +1058,10 @@ async def register_email_standalone(
referred_by_id=referrer.id if referrer else None,
)
# Сохранить campaign_slug для обработки при верификации email
if request.campaign_slug:
user.pending_campaign_slug = request.campaign_slug
# Для тестового email или отключённой верификации - автоматически верифицировать
if is_test_email or not settings.is_cabinet_email_verification_enabled():
user.email_verified = True
@@ -1063,6 +1073,11 @@ async def register_email_standalone(
await _sync_subscription_from_panel_by_email(db, user)
except Exception:
logger.warning('Failed to sync panel subscription after auto-verify', user_id=user.id, exc_info=True)
# Process campaign bonus immediately for auto-verified users
if request.campaign_slug:
await _process_campaign_bonus(db, user, request.campaign_slug)
user.pending_campaign_slug = None
await db.commit()
else:
# Сгенерировать токен верификации
verification_token = generate_verification_token()
@@ -1173,8 +1188,12 @@ async def verify_email(
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
# Process campaign bonus (prefer request param, fallback to saved slug from registration)
effective_campaign_slug = request.campaign_slug or user.pending_campaign_slug
response.campaign_bonus = await _process_campaign_bonus(db, user, effective_campaign_slug)
if user.pending_campaign_slug:
user.pending_campaign_slug = None
await db.commit()
if response.campaign_bonus:
response.user = _user_to_response(user)
+60
View File
@@ -794,6 +794,66 @@ async def create_topup(
detail='Failed to create SeverPay payment',
)
elif request.payment_method == 'paypear':
if not settings.is_paypear_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='PayPear payment method is unavailable',
)
payment_service = PaymentService()
result = await payment_service.create_paypear_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create PayPear payment',
)
elif request.payment_method == 'rollypay':
if not settings.is_rollypay_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='RollyPay payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_rollypay_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_type=payment_method_type,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create RollyPay payment',
)
else:
# For other payment methods, redirect to bot
raise HTTPException(
+2 -2
View File
@@ -28,7 +28,7 @@ class NotificationSettingsResponse(BaseModel):
subscription_expiry_days: int = 3
traffic_warning_enabled: bool = True
traffic_warning_percent: int = 80
balance_low_enabled: bool = True
balance_low_enabled: bool = False
balance_low_threshold: int = 100 # kopeks
news_enabled: bool = True
promo_offers_enabled: bool = True
@@ -60,7 +60,7 @@ def _get_notification_settings(user: User) -> dict[str, Any]:
'subscription_expiry_days': settings_data.get('subscription_expiry_days', 3),
'traffic_warning_enabled': settings_data.get('traffic_warning_enabled', True),
'traffic_warning_percent': settings_data.get('traffic_warning_percent', 80),
'balance_low_enabled': settings_data.get('balance_low_enabled', True),
'balance_low_enabled': settings_data.get('balance_low_enabled', False),
'balance_low_threshold': settings_data.get('balance_low_threshold', 100),
'news_enabled': settings_data.get('news_enabled', True),
'promo_offers_enabled': settings_data.get('promo_offers_enabled', True),
@@ -168,6 +168,13 @@ async def toggle_subscription_pause(
)
except Exception as e:
logger.error('Error syncing RemnaWave user on resume', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create',
)
if new_paused_state:
message = 'Daily subscription paused'
@@ -228,12 +228,24 @@ async def purchase_devices_legacy(
# Sync with RemnaWave
try:
service = SubscriptionService()
if _resolve_panel_uuid(subscription, user):
await service.update_remnawave_user(db, subscription)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(user, 'remnawave_uuid', None)
if _should_create:
await service.create_remnawave_user(db, subscription)
else:
await service.update_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync devices with RemnaWave (legacy endpoint)', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create' if _should_create else 'update',
)
# Отправляем уведомление админам
try:
@@ -469,12 +481,24 @@ async def purchase_devices(
# Sync with RemnaWave
service = SubscriptionService()
try:
if _resolve_panel_uuid(subscription, user):
await service.update_remnawave_user(db, subscription)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(user, 'remnawave_uuid', None)
if _should_create:
await service.create_remnawave_user(db, subscription)
else:
await service.update_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync devices with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create' if _should_create else 'update',
)
await db.refresh(user)
@@ -911,6 +911,13 @@ async def purchase_tariff(
)
except Exception as remnawave_error:
logger.error('Failed to sync subscription with RemnaWave', remnawave_error=remnawave_error)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create' if not subscription.remnawave_uuid else 'update',
)
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
if not is_daily_tariff:
@@ -1228,6 +1235,13 @@ async def activate_trial(
except Exception as e:
logger.error('Error getting trial tariff', error=e)
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
if not trial_squads:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_squad_uuid = await get_random_trial_squad_uuid(db)
trial_squads = [trial_squad_uuid] if trial_squad_uuid else []
# Create trial subscription
subscription = await create_trial_subscription(
db=db,
@@ -1249,6 +1263,13 @@ async def activate_trial(
await db.refresh(subscription)
except Exception as e:
logger.error('Failed to create RemnaWave user for trial', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create',
)
# Send admin notification about trial activation
try:
@@ -249,6 +249,13 @@ async def update_countries(
await subscription_service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync countries with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='update' if _has_panel else 'create',
)
await db.refresh(subscription)
@@ -428,6 +428,13 @@ async def switch_tariff(
)
except Exception as e:
logger.error('Failed to sync tariff switch with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='update' if _has_panel else 'create',
)
# Reset all devices on tariff switch
devices_reset = False
@@ -316,19 +316,32 @@ async def purchase_traffic(
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
_panel_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else getattr(user, 'remnawave_uuid', None)
)
if _panel_uuid:
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(db, subscription)
else:
await subscription_service.update_remnawave_user(db, subscription)
if subscription.status == 'active':
await subscription_service.enable_remnawave_user(_panel_uuid)
else:
await subscription_service.create_remnawave_user(db, subscription)
_enable_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled()
else getattr(user, 'remnawave_uuid', None)
)
if _enable_uuid:
await subscription_service.enable_remnawave_user(_enable_uuid)
except Exception as e:
logger.error('Failed to sync traffic with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create' if _should_create else 'update',
)
# Создаём транзакцию
await create_transaction(
@@ -604,17 +617,25 @@ async def switch_traffic_package(
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
_panel_uuid2 = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else getattr(user, 'remnawave_uuid', None)
)
if _panel_uuid2:
await subscription_service.update_remnawave_user(db, subscription)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(db, subscription)
else:
await subscription_service.update_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync traffic switch with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create' if _should_create else 'update',
)
await db.refresh(user)
await db.refresh(subscription)
+3
View File
@@ -138,6 +138,9 @@ class EmailRegisterStandaloneRequest(BaseModel):
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
)
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
class CampaignBonusInfo(BaseModel):
+7
View File
@@ -118,6 +118,7 @@ class BroadcastCreateRequest(BaseModel):
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
media: BroadcastMediaRequest | None = None
category: str = Field(default='system', pattern='^(system|news|promo)$')
# ============ Response ============
@@ -144,6 +145,9 @@ class BroadcastResponse(BaseModel):
completed_at: datetime | None = None
progress_percent: float = 0.0
# Category for user notification preference filtering
category: str = 'system' # system|news|promo
# Email/channel fields
channel: str = 'telegram' # telegram|email|both
email_subject: str | None = None
@@ -212,6 +216,9 @@ class CombinedBroadcastCreateRequest(BaseModel):
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
media: BroadcastMediaRequest | None = None
# Broadcast category for user notification preference filtering
category: str = Field(default='system', pattern='^(system|news|promo)$')
# Email-specific fields
email_subject: str | None = Field(default=None, max_length=255)
email_html_content: str | None = Field(default=None, max_length=100000)
+10 -1
View File
@@ -12,7 +12,16 @@ def get_campaign_deep_link(start_parameter: str) -> str:
def get_campaign_web_link(start_parameter: str) -> str | None:
"""Generate a web app link for a campaign."""
"""Generate a web app link for a campaign.
Prefers CABINET_URL (where the auth flow captures ?campaign= param),
falls back to MINIAPP_CUSTOM_URL for backwards compatibility.
"""
cabinet_url = settings._normalized_cabinet_url()
if cabinet_url:
sep = '&' if '?' in cabinet_url else '?'
return f'{cabinet_url}{sep}campaign={start_parameter}'
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
if base_url:
return f'{base_url}/?campaign={start_parameter}'
+55 -2
View File
@@ -68,9 +68,9 @@ class Settings(BaseSettings):
ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID: int | None = None # Партнёрки, выводы, админ-действия
# Настройки очереди чеков NaloGO
NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды)
NALOGO_QUEUE_CHECK_INTERVAL: int = 600 # Интервал проверки очереди (секунды, 10 мин)
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
NALOGO_QUEUE_MAX_ATTEMPTS: int = 10 # Максимум попыток отправки чека
NALOGO_QUEUE_MAX_ATTEMPTS: int = 72 # Максимум попыток отправки чека (72 × 10мин = 12 часов)
ADMIN_REPORTS_ENABLED: bool = False
ADMIN_REPORTS_CHAT_ID: str | None = None
@@ -324,6 +324,7 @@ class Settings(BaseSettings):
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS: int = 20000
MONITORING_INTERVAL: int = 60
LOW_BALANCE_ALERT_EXPIRY_DAYS: int = 3 # Only alert when subscription expires within N days
INACTIVE_USER_DELETE_MONTHS: int = 3
MAINTENANCE_MODE: bool = False
@@ -436,6 +437,7 @@ class Settings(BaseSettings):
MULENPAY_LANGUAGE: str = 'ru'
MULENPAY_VAT_CODE: int = 0
DISPLAY_NAME_RESTRICTION_ENABLED: bool = True
DISPLAY_NAME_BANNED_KEYWORDS: str = '\n'.join(DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS)
MULENPAY_PAYMENT_SUBJECT: int = 4
MULENPAY_PAYMENT_MODE: int = 4
@@ -586,6 +588,29 @@ class Settings(BaseSettings):
SEVERPAY_RETURN_URL: str | None = None
SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320
# PayPear (paypear.ru)
PAYPEAR_ENABLED: bool = False
PAYPEAR_SHOP_ID: str | None = None
PAYPEAR_SECRET_KEY: str | None = None
PAYPEAR_DISPLAY_NAME: str = 'PayPear'
PAYPEAR_CURRENCY: str = 'RUB'
PAYPEAR_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
PAYPEAR_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
PAYPEAR_WEBHOOK_PATH: str = '/paypear-webhook'
PAYPEAR_RETURN_URL: str | None = None
PAYPEAR_PAYMENT_METHOD: str = 'sbp' # bank_card, sbp, sberpay, tpay
# RollyPay (rollypay.io)
ROLLYPAY_ENABLED: bool = False
ROLLYPAY_API_KEY: str | None = None # X-API-Key header
ROLLYPAY_SIGNING_SECRET: str | None = None # HMAC webhook verification
ROLLYPAY_DISPLAY_NAME: str = 'RollyPay'
ROLLYPAY_CURRENCY: str = 'RUB'
ROLLYPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
ROLLYPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
ROLLYPAY_WEBHOOK_PATH: str = '/rollypay-webhook'
ROLLYPAY_RETURN_URL: str | None = None
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
CABINET_BUTTON_STYLE: str = ''
@@ -825,6 +850,10 @@ class Settings(BaseSettings):
# Format: socks5://user:password@host:port or socks5://host:port
PROXY_URL: str | None = None
# Custom Telegram Bot API server URL (for regions where api.telegram.org is blocked)
# Examples: Cloudflare Worker proxy, self-hosted telegram-bot-api (tdlib), nginx reverse proxy
TELEGRAM_API_URL: str | None = None
@field_validator('PROXY_URL', 'NALOGO_PROXY_URL', mode='before')
@classmethod
def validate_proxy_url(cls, value: str | None) -> str | None:
@@ -972,6 +1001,10 @@ class Settings(BaseSettings):
"""Return SOCKS5 proxy URL or None."""
return self.PROXY_URL if self.PROXY_URL else None
def get_telegram_api_url(self) -> str | None:
"""Return custom Telegram Bot API server URL or None."""
return self.TELEGRAM_API_URL if self.TELEGRAM_API_URL else None
def get_nalogo_proxy_url(self) -> str | None:
"""Return SOCKS proxy URL for nalogo or None.
@@ -1963,6 +1996,26 @@ class Settings(BaseSettings):
def get_severpay_display_name_html(self) -> str:
return html.escape(self.get_severpay_display_name())
def is_paypear_enabled(self) -> bool:
return self.PAYPEAR_ENABLED and self.PAYPEAR_SHOP_ID is not None and self.PAYPEAR_SECRET_KEY is not None
def get_paypear_display_name(self) -> str:
name = (self.PAYPEAR_DISPLAY_NAME or '').strip()
return name if name else 'PayPear'
def get_paypear_display_name_html(self) -> str:
return html.escape(self.get_paypear_display_name())
def is_rollypay_enabled(self) -> bool:
return self.ROLLYPAY_ENABLED and self.ROLLYPAY_API_KEY is not None and self.ROLLYPAY_SIGNING_SECRET is not None
def get_rollypay_display_name(self) -> str:
name = (self.ROLLYPAY_DISPLAY_NAME or '').strip()
return name if name else 'RollyPay'
def get_rollypay_display_name_html(self) -> str:
return html.escape(self.get_rollypay_display_name())
def is_kassa_ai_sbp_enabled(self) -> bool:
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
+157
View File
@@ -0,0 +1,157 @@
"""CRUD операции для платежей PayPear."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PayPearPayment
logger = structlog.get_logger(__name__)
async def create_paypear_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
paypear_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> PayPearPayment:
"""Создает запись о платеже PayPear."""
payment = PayPearPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
paypear_id=paypear_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж PayPear', order_id=order_id, user_id=user_id)
return payment
async def get_paypear_payment_by_order_id(db: AsyncSession, order_id: str) -> PayPearPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(PayPearPayment).where(PayPearPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_paypear_payment_by_paypear_id(db: AsyncSession, paypear_id: str) -> PayPearPayment | None:
"""Получает платеж по ID от PayPear."""
result = await db.execute(select(PayPearPayment).where(PayPearPayment.paypear_id == paypear_id))
return result.scalar_one_or_none()
async def get_paypear_payment_by_id(db: AsyncSession, payment_id: int) -> PayPearPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(PayPearPayment).where(PayPearPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_paypear_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> PayPearPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(
select(PayPearPayment)
.where(PayPearPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_paypear_payment_status(
db: AsyncSession,
payment: PayPearPayment,
*,
status: str,
is_paid: bool | None = None,
paypear_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> PayPearPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if paypear_id is not None:
payment.paypear_id = paypear_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен статус платежа PayPear',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_paypear_payments(db: AsyncSession, user_id: int) -> list[PayPearPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(PayPearPayment).where(
PayPearPayment.user_id == user_id,
PayPearPayment.status == 'pending',
PayPearPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_paypear_payments(
db: AsyncSession,
) -> list[PayPearPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(PayPearPayment).where(
PayPearPayment.status == 'pending',
PayPearPayment.is_paid == False,
PayPearPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_paypear_payment_to_transaction(
db: AsyncSession,
*,
payment: PayPearPayment,
transaction_id: int,
) -> PayPearPayment:
"""Связывает платеж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+1 -1
View File
@@ -166,7 +166,7 @@ async def update_promo_group(
group.device_discount_percent = max(0, min(100, device_discount_percent))
if period_discounts is not None:
normalized_period_discounts = _normalize_period_discounts(period_discounts)
group.period_discounts = normalized_period_discounts or None
group.period_discounts = normalized_period_discounts if normalized_period_discounts else None
if auto_assign_total_spent_kopeks is not None:
value = max(0, auto_assign_total_spent_kopeks)
group.auto_assign_total_spent_kopeks = value if value > 0 else None
+157
View File
@@ -0,0 +1,157 @@
"""CRUD операции для платежей RollyPay."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import RollyPayPayment
logger = structlog.get_logger(__name__)
async def create_rollypay_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
rollypay_payment_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> RollyPayPayment:
"""Создает запись о платеже RollyPay."""
payment = RollyPayPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
rollypay_payment_id=rollypay_payment_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж RollyPay', order_id=order_id, user_id=user_id)
return payment
async def get_rollypay_payment_by_order_id(db: AsyncSession, order_id: str) -> RollyPayPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(RollyPayPayment).where(RollyPayPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_rollypay_payment_by_rollypay_id(db: AsyncSession, rollypay_payment_id: str) -> RollyPayPayment | None:
"""Получает платеж по ID от RollyPay."""
result = await db.execute(select(RollyPayPayment).where(RollyPayPayment.rollypay_payment_id == rollypay_payment_id))
return result.scalar_one_or_none()
async def get_rollypay_payment_by_id(db: AsyncSession, payment_id: int) -> RollyPayPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(RollyPayPayment).where(RollyPayPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_rollypay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> RollyPayPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(
select(RollyPayPayment)
.where(RollyPayPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_rollypay_payment_status(
db: AsyncSession,
payment: RollyPayPayment,
*,
status: str,
is_paid: bool | None = None,
rollypay_payment_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> RollyPayPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if rollypay_payment_id is not None:
payment.rollypay_payment_id = rollypay_payment_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен статус платежа RollyPay',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_rollypay_payments(db: AsyncSession, user_id: int) -> list[RollyPayPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(RollyPayPayment).where(
RollyPayPayment.user_id == user_id,
RollyPayPayment.status == 'pending',
RollyPayPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_rollypay_payments(
db: AsyncSession,
) -> list[RollyPayPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(RollyPayPayment).where(
RollyPayPayment.status == 'pending',
RollyPayPayment.is_paid == False,
RollyPayPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_rollypay_payment_to_transaction(
db: AsyncSession,
*,
payment: RollyPayPayment,
transaction_id: int,
) -> RollyPayPayment:
"""Связывает платеж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+6
View File
@@ -122,6 +122,12 @@ async def clear_trial_tariff(db: AsyncSession) -> None:
await db.commit()
async def get_all_active_tariffs(db: AsyncSession) -> list[Tariff]:
"""Get all active tariffs."""
result = await db.execute(select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.tier_level))
return list(result.scalars().all())
async def get_tariffs_for_user(
db: AsyncSession,
promo_group_id: int | None = None,
+132 -8
View File
@@ -162,6 +162,8 @@ class PaymentMethod(Enum):
KASSA_AI = 'kassa_ai'
RIOPAY = 'riopay'
SEVERPAY = 'severpay'
PAYPEAR = 'paypear'
ROLLYPAY = 'rollypay'
MANUAL = 'manual'
BALANCE = 'balance'
@@ -878,6 +880,130 @@ class SeverPayPayment(Base):
return f'<SeverPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class PayPearPayment(Base):
"""Платежи через PayPear (paypear.ru)."""
__tablename__ = 'paypear_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
paypear_id = Column(String(64), unique=True, nullable=True, index=True) # ID от PayPear
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_method = Column(String(32), nullable=True)
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='paypear_payments')
transaction = relationship('Transaction', backref='paypear_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == 'pending'
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'canceled', 'amount_mismatch']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<PayPearPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class RollyPayPayment(Base):
"""Платежи через RollyPay (rollypay.io)."""
__tablename__ = 'rollypay_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
rollypay_payment_id = Column(String(128), unique=True, nullable=True, index=True) # pay_uuid от RollyPay
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_method = Column(String(32), nullable=True)
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='rollypay_payments')
transaction = relationship('Transaction', backref='rollypay_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == 'pending'
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'canceled', 'chargeback', 'amount_mismatch']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<RollyPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class PromoGroup(Base):
__tablename__ = 'promo_groups'
@@ -933,13 +1059,6 @@ class PromoGroup(Base):
if period_days in discounts:
return discounts[period_days]
# For daily tariffs (period_days=1): fallback to the smallest configured period discount.
# Admins configure discounts for standard periods (30, 90, 180, 360) but not for daily.
# If all periods have 100% discount, daily should too.
if period_days <= 1 and discounts:
smallest_period = min(discounts)
return discounts[smallest_period]
if self.is_default:
try:
from app.config import settings
@@ -1211,6 +1330,8 @@ class User(Base):
password_reset_token = Column(String(255), nullable=True)
password_reset_expires = Column(AwareDateTime(), nullable=True)
cabinet_last_login = Column(AwareDateTime(), nullable=True)
# Campaign slug saved at registration, consumed at email verification
pending_campaign_slug = Column(String(64), nullable=True)
# Email change fields
email_change_new = Column(String(255), nullable=True) # New email pending verification
email_change_code = Column(String(6), nullable=True) # 6-digit verification code
@@ -1262,7 +1383,7 @@ class User(Base):
user_promo_groups = relationship('UserPromoGroup', back_populates='user', cascade='all, delete-orphan')
poll_responses = relationship('PollResponse', back_populates='user')
admin_roles_rel = relationship('UserRole', foreign_keys='[UserRole.user_id]', back_populates='user')
notification_settings = Column(JSON, nullable=True, default=dict)
notification_settings = Column(JSONB, nullable=True, default=dict)
last_pinned_message_id = Column(Integer, nullable=True)
# Ограничения пользователя
@@ -2245,6 +2366,9 @@ class BroadcastHistory(Base):
created_at = Column(AwareDateTime(), server_default=func.now())
completed_at = Column(AwareDateTime(), nullable=True)
# Broadcast category for user notification preferences filtering
category = Column(String(20), default='system', nullable=False) # system|news|promo
# Email broadcast fields
channel = Column(String(20), default='telegram', nullable=False) # telegram|email|both
email_subject = Column(String(255), nullable=True)
+1 -1
View File
@@ -446,7 +446,7 @@ class RemnaWaveAPI:
'trafficLimitStrategy': traffic_limit_strategy.value,
}
if telegram_id:
if telegram_id is not None:
data['telegramId'] = telegram_id
if email:
data['email'] = email
+14 -22
View File
@@ -1599,42 +1599,28 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
result = await db.execute(query)
return result.scalar() or 0
if target == 'expired':
# Истекшие подписки
if target in ('expired', 'expired_subscribers'):
# Истекшие подписки — исключаем юзеров с хотя бы одной активной
now = datetime.now(UTC)
expired_statuses = [
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.LIMITED.value,
]
query = (
select(sql_func.count(distinct(User.id)))
.outerjoin(Subscription, User.id == Subscription.user_id)
has_active_sub = (
select(Subscription.id)
.where(
base_filter,
or_(
Subscription.status.in_(expired_statuses),
and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value),
and_(Subscription.id == None, User.has_had_paid_subscription == True),
),
Subscription.user_id == User.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
.exists()
)
result = await db.execute(query)
return result.scalar() or 0
if target == 'expired_subscribers':
# То же что и expired
now = datetime.now(UTC)
expired_statuses = [
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.LIMITED.value,
]
query = (
select(sql_func.count(distinct(User.id)))
.outerjoin(Subscription, User.id == Subscription.user_id)
.where(
base_filter,
~has_active_sub,
or_(
Subscription.status.in_(expired_statuses),
and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value),
@@ -1785,6 +1771,9 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
for user in users:
subs = getattr(user, 'subscriptions', None) or []
if subs:
has_active = any(s.is_active for s in subs)
if has_active:
continue # Skip users who have at least one active subscription
has_expired = any(s.status in expired_statuses or (s.end_date <= now and not s.is_active) for s in subs)
if has_expired:
expired_users.append(user)
@@ -1833,6 +1822,9 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
for user in users:
subs = getattr(user, 'subscriptions', None) or []
if subs:
has_active = any(s.is_active for s in subs)
if has_active:
continue # Skip users who have at least one active subscription
has_expired = any(s.status in expired_statuses or (s.end_date <= now and not s.is_active) for s in subs)
if has_expired:
expired_users.append(user)
+1 -1
View File
@@ -77,7 +77,7 @@ def _build_server_edit_view(server):
],
[
types.InlineKeyboardButton(
text='🎁 Выдавать сквад' if not server.is_trial_eligible else '🚫 Не выдавать сквад',
text='🎁 Выдавать в триал' if not server.is_trial_eligible else '🚫 Не выдавать в триал',
callback_data=f'admin_server_trial_{server.id}',
),
],
+31 -19
View File
@@ -3821,26 +3821,38 @@ async def start_devices_edit(callback: types.CallbackQuery, db_user: User, state
else f'admin_user_subscription_{user_id}'
)
await callback.message.edit_text(
'📱 <b>Изменение количества устройств</b>\n\n'
'Введите новое количество устройств (от 1 до 10):\n'
'• Текущее значение будет заменено\n'
'• Примеры: 1, 2, 5, 10\n\n'
'Или нажмите /cancel для отмены',
reply_markup=types.InlineKeyboardMarkup(
max_dev = settings.MAX_DEVICES_LIMIT
# Build device buttons dynamically: rows of 4, respect Telegram 100 button limit (99 + cancel)
if max_dev <= 99:
device_buttons: list[list[types.InlineKeyboardButton]] = []
row: list[types.InlineKeyboardButton] = []
for i in range(1, max_dev + 1):
row.append(
types.InlineKeyboardButton(
text=str(i),
callback_data=f'admin_user_devices_set_{user_id}{_sid}_{i}',
)
)
if len(row) == 4:
device_buttons.append(row)
row = []
if row:
device_buttons.append(row)
device_buttons.append([types.InlineKeyboardButton(text='❌ Отмена', callback_data=back_cb)])
markup = types.InlineKeyboardMarkup(inline_keyboard=device_buttons)
else:
markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(text='1', callback_data=f'admin_user_devices_set_{user_id}{_sid}_1'),
types.InlineKeyboardButton(text='2', callback_data=f'admin_user_devices_set_{user_id}{_sid}_2'),
types.InlineKeyboardButton(text='3', callback_data=f'admin_user_devices_set_{user_id}{_sid}_3'),
],
[
types.InlineKeyboardButton(text='5', callback_data=f'admin_user_devices_set_{user_id}{_sid}_5'),
types.InlineKeyboardButton(text='10', callback_data=f'admin_user_devices_set_{user_id}{_sid}_10'),
],
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=back_cb)],
]
),
)
await callback.message.edit_text(
'📱 <b>Изменение количества устройств</b>\n\n'
f'Введите новое количество устройств (от 1 до {max_dev}):\n'
'• Текущее значение будет заменено\n\n'
'Или нажмите /cancel для отмены',
reply_markup=markup,
)
await state.set_state(AdminStates.editing_user_devices)
@@ -3910,8 +3922,8 @@ async def process_devices_edit_text(message: types.Message, db_user: User, state
try:
devices = int(message.text.strip())
if devices <= 0 or devices > 10:
await message.answer('❌ Количество устройств должно быть от 1 до 10')
if devices <= 0 or devices > settings.MAX_DEVICES_LIMIT:
await message.answer(f'❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}')
return
success = await _update_user_devices(db, user_id, devices, db_user.id, subscription_id=subscription_id)
+22
View File
@@ -149,6 +149,20 @@ async def route_payment_by_method(
await process_severpay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'paypear':
from .paypear import process_paypear_payment_amount
async with AsyncSessionLocal() as db:
await process_paypear_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'rollypay':
from .rollypay import process_rollypay_payment_amount
async with AsyncSessionLocal() as db:
await process_rollypay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'riopay':
from .riopay import process_riopay_payment_amount
@@ -719,6 +733,14 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_severpay_topup, F.data == 'topup_severpay')
from .paypear import start_paypear_topup
dp.callback_query.register(start_paypear_topup, F.data == 'topup_paypear')
from .rollypay import start_rollypay_topup
dp.callback_query.register(start_rollypay_topup, F.data == 'topup_rollypay')
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
+247
View File
@@ -0,0 +1,247 @@
"""Handler for PayPear balance top-up."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _create_paypear_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
):
"""
Common logic for creating PayPear payment and sending response.
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_paypear_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(
error_text,
parse_mode='HTML',
)
return
payment_url = result.get('payment_url')
display_name = settings.get_paypear_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'\U0001f4b3 Оплатить {amount}\u20bd',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'PAYPEAR_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}\u20bd</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info('PayPear payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_paypear_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
# Validate amount
min_amount = settings.PAYPEAR_MIN_AMOUNT_KOPEKS
max_amount = settings.PAYPEAR_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}\u20bd',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}\u20bd',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
await state.clear()
await _create_paypear_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
)
@error_handler
async def start_paypear_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Start PayPear top-up process - ask for amount.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method='paypear')
min_amount = settings.PAYPEAR_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.PAYPEAR_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_paypear_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'PAYPEAR_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\u20bd\n'
'Максимум: {max_amount}\u20bd',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
+247
View File
@@ -0,0 +1,247 @@
"""Handler for RollyPay balance top-up."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _create_rollypay_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
):
"""
Common logic for creating RollyPay payment and sending response.
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_rollypay_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(
error_text,
parse_mode='HTML',
)
return
payment_url = result.get('payment_url')
display_name = settings.get_rollypay_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'\U0001f4b3 Оплатить {amount}\u20bd',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'ROLLYPAY_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}\u20bd</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info('RollyPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_rollypay_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
# Validate amount
min_amount = settings.ROLLYPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.ROLLYPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}\u20bd',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}\u20bd',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
await state.clear()
await _create_rollypay_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
)
@error_handler
async def start_rollypay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Start RollyPay top-up process - ask for amount.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method='rollypay')
min_amount = settings.ROLLYPAY_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.ROLLYPAY_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_rollypay_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'ROLLYPAY_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\u20bd\n'
'Максимум: {max_amount}\u20bd',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
+6 -2
View File
@@ -169,7 +169,9 @@ async def show_main_menu(
await db.commit()
# Multi-tariff aware: check if user has ANY active subscription
has_active_subscription = any(sub.is_active for sub in (getattr(db_user, 'subscriptions', None) or []))
# 'limited' (traffic exhausted) subscriptions are still active for UI purposes
_subs = getattr(db_user, 'subscriptions', None) or []
has_active_subscription = any(sub.is_active or getattr(sub, 'actual_status', None) == 'limited' for sub in _subs)
subscription_is_active = has_active_subscription
menu_text = await get_main_menu_text(db_user, texts, db)
@@ -1013,7 +1015,9 @@ async def handle_back_to_menu(callback: types.CallbackQuery, state: FSMContext,
texts = get_texts(db_user.language)
# Multi-tariff aware: check if user has ANY active subscription
has_active_subscription = any(sub.is_active for sub in (getattr(db_user, 'subscriptions', None) or []))
# 'limited' (traffic exhausted) subscriptions are still active for UI purposes
_subs = getattr(db_user, 'subscriptions', None) or []
has_active_subscription = any(sub.is_active or getattr(sub, 'actual_status', None) == 'limited' for sub in _subs)
subscription_is_active = has_active_subscription
menu_text = await get_main_menu_text(db_user, texts, db)
+16
View File
@@ -549,6 +549,14 @@ async def handle_simple_subscription_pay_with_balance(
sync_error=sync_error,
exc_info=True,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
# Отправляем уведомление об успешной покупке
server_label = _get_simple_subscription_server_label(
@@ -2289,6 +2297,14 @@ async def confirm_simple_subscription_purchase(
sync_error=sync_error,
exc_info=True,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
# Отправляем уведомление об успешной покупке
server_label = _get_simple_subscription_server_label(
+8
View File
@@ -253,6 +253,14 @@ async def _handle_trial_payment(
except Exception as rw_error:
logger.error('Ошибка создания пользователя RemnaWave для триала', rw_error=rw_error)
# Не откатываем подписку, просто логируем - RemnaWave может быть временно недоступен
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
await db.commit()
await db.refresh(user)
+23 -2
View File
@@ -234,6 +234,14 @@ async def _claim_phantom_user(
subscription_id=phantom_sub.id,
error=str(exc),
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(phantom_sub, 'id') and hasattr(phantom_sub, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=phantom_sub.id,
user_id=phantom_sub.user_id,
action='update',
)
return True, phantom
@@ -336,8 +344,9 @@ def _calculate_subscription_flags(subscription):
return False, False
actual_status = getattr(subscription, 'actual_status', None)
has_active_subscription = actual_status in {'active', 'trial'}
subscription_is_active = bool(getattr(subscription, 'is_active', False))
# 'limited' subscriptions are still active (traffic exhausted, but subscription not expired)
has_active_subscription = actual_status in {'active', 'trial', 'limited'}
subscription_is_active = bool(getattr(subscription, 'is_active', False)) or actual_status == 'limited'
return has_active_subscription, subscription_is_active
@@ -2443,6 +2452,18 @@ async def required_sub_channel_check(
telegram_id=user.telegram_id if user else query.from_user.id,
api_error=api_error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
for sub in _subs:
if sub.is_trial and sub.status == SubscriptionStatus.ACTIVE.value:
if hasattr(sub, 'id') and hasattr(sub, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=sub.id,
user_id=sub.user_id,
action='update'
if (getattr(sub, 'remnawave_uuid', None) or user.remnawave_uuid)
else 'create',
)
await query.answer(
texts.t('CHANNEL_SUBSCRIBE_THANKS', '✅ Спасибо за подписку'),
+12 -1
View File
@@ -412,7 +412,18 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
await db.commit()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
try:
await subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
except Exception as rw_err:
logger.error('Ошибка синхронизации с RemnaWave при смене стран', error=rw_err)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
await db.refresh(subscription)
+57 -24
View File
@@ -937,6 +937,13 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
except Exception as e:
logger.error('Ошибка получения триального тарифа', error=e)
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
if not trial_squads:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_squad_uuid = await get_random_trial_squad_uuid(db)
trial_squads = [trial_squad_uuid] if trial_squad_uuid else []
subscription = await create_trial_subscription(
db,
db_user.id,
@@ -2579,17 +2586,19 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
subscription_service = SubscriptionService()
# При покупке подписки ВСЕГДА сбрасываем трафик в панели
_purchase_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else db_user.remnawave_uuid
)
if settings.is_multi_tariff_enabled() and not getattr(subscription, 'remnawave_uuid', None):
logger.warning(
'Multi-tariff: subscription missing remnawave_uuid, using user fallback',
subscription_id=getattr(subscription, 'id', None),
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(db_user, 'remnawave_uuid', None)
if _should_create:
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка подписки',
)
if _purchase_uuid:
else:
remnawave_user = await subscription_service.update_remnawave_user(
db,
subscription,
@@ -2597,22 +2606,25 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
reset_reason='покупка подписки',
sync_squads=True,
)
else:
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка подписки',
)
if not remnawave_user:
logger.error('Не удалось создать/обновить RemnaWave пользователя для', telegram_id=db_user.telegram_id)
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка подписки (повторная попытка)',
)
logger.error('Не удалось создать/обновить RemnaWave пользователя', telegram_id=db_user.telegram_id)
try:
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка подписки (повторная попытка)',
)
except Exception as retry_error:
logger.error('Повторная попытка создания RemnaWave пользователя не удалась', error=retry_error)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
transaction = await create_transaction(
db=db,
@@ -3162,6 +3174,13 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
)
except Exception as e:
logger.error('Ошибка синхронизации с Remnawave при возобновлении', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='update',
)
# Отправляем уведомление администраторам о возобновлении суточной подписки
if resume_transaction is not None:
@@ -3303,6 +3322,13 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
except Exception as e:
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
if not trial_squads:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_squad_uuid = await get_random_trial_squad_uuid(db)
trial_squads = [trial_squad_uuid] if trial_squad_uuid else []
subscription = await create_trial_subscription(
db,
db_user.id,
@@ -4594,6 +4620,13 @@ async def _extend_existing_subscription(
logger.error('⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE')
except Exception as e:
logger.error('⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=current_subscription.id,
user_id=db_user.id,
action='update',
)
# Создаём транзакцию
transaction = await create_transaction(
+185 -42
View File
@@ -1057,14 +1057,34 @@ async def handle_custom_confirm(
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(db_user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
)
else:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Создаем транзакцию
await create_transaction(
@@ -1568,14 +1588,37 @@ async def confirm_tariff_purchase(
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
)
# In multi-tariff mode, each subscription has its own panel user.
# A new subscription has no remnawave_uuid yet, so always CREATE.
# In single-tariff mode, reuse the user-level UUID if available.
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(db_user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
)
else:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Создаем транзакцию
try:
@@ -1828,14 +1871,34 @@ async def confirm_daily_tariff_purchase(
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка суточного тарифа',
)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(db_user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка суточного тарифа',
)
else:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка суточного тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Создаем транзакцию
await create_transaction(
@@ -2280,14 +2343,34 @@ async def confirm_tariff_extend(
# Обновляем пользователя в Remnawave
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT or was_trial,
reset_reason='конвертация триала' if was_trial else 'продление тарифа',
)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(db_user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT or was_trial,
reset_reason='конвертация триала' if was_trial else 'продление тарифа',
)
else:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT or was_trial,
reset_reason='конвертация триала' if was_trial else 'продление тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Создаем транзакцию
await create_transaction(
@@ -2893,14 +2976,34 @@ async def confirm_tariff_switch(
# Обновляем пользователя в Remnawave
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='переключение тарифа',
)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(db_user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='переключение тарифа',
)
else:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='переключение тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave при переключении тарифа', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Гарантированный сброс устройств при смене тарифа
await db.refresh(db_user)
@@ -3121,14 +3224,34 @@ async def confirm_daily_tariff_switch(
# Обновляем пользователя в Remnawave (сброс трафика по админ-настройке)
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='смена на суточный тариф',
)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(db_user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='смена на суточный тариф',
)
else:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='смена на суточный тариф',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Гарантированный сброс устройств при смене тарифа
await db.refresh(db_user)
@@ -3795,14 +3918,34 @@ async def confirm_instant_switch(
# Обновляем пользователя в Remnawave (сброс трафика по админ-настройке)
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='мгновенное переключение тарифа',
)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(db_user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='мгновенное переключение тарифа',
)
else:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='мгновенное переключение тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave при мгновенном переключении', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Гарантированный сброс устройств при смене тарифа
await db.refresh(db_user)
+8 -1
View File
@@ -983,7 +983,14 @@ async def close_ticket_notification(callback: types.CallbackQuery, db_user: User
await callback.answer()
return
await callback.message.delete()
try:
await callback.message.delete()
except TelegramBadRequest:
# Message is too old to delete (>48h) — edit it instead
try:
await callback.message.edit_text(texts.t('NOTIFICATION_CLOSED', 'Уведомление закрыто.'))
except TelegramBadRequest:
pass
await callback.answer(texts.t('NOTIFICATION_CLOSED', 'Уведомление закрыто.'))
+24
View File
@@ -1807,6 +1807,30 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_paypear_enabled():
paypear_name = settings.get_paypear_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_PAYPEAR', f'💳 Оплата ({paypear_name})'),
callback_data=_build_callback('paypear'),
)
]
)
has_direct_payment_methods = True
if settings.is_rollypay_enabled():
rollypay_name = settings.get_rollypay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ROLLYPAY', f'💳 {rollypay_name}'),
callback_data=_build_callback('rollypay'),
)
]
)
has_direct_payment_methods = True
if settings.is_support_topup_enabled():
keyboard.append(
[
+3 -1
View File
@@ -1756,5 +1756,7 @@
"WEBHOOK_DEVICE_ADDED": "📱 <b>New device</b>\n\nA new device has been added to your subscription{tariff_label}: <code>{device}</code>",
"WEBHOOK_DEVICE_DELETED": "📱 <b>Device removed</b>\n\nA device has been removed from your subscription{tariff_label}: <code>{device}</code>",
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Torrent detected</b>\n\nTorrent traffic was detected on your connection{tariff_label}. Using torrents may result in subscription restrictions.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Close"
"WEBHOOK_CLOSE_BUTTON": "✖️ Close",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Traffic Warning</b>\n\nUsed: {used:.1f} / {limit} GB ({percent:.0f}%)\n\nYour traffic limit is almost reached.",
"LOW_BALANCE_ALERT": "⚠️ <b>Low Balance</b>\n\nYour balance: {balance} ₽\nNotification threshold: {threshold} ₽\n\nTop up your balance to ensure automatic subscription renewal."
}
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -1749,19 +1749,16 @@
"MODEM_PRICE_WITH_DISCOUNT": "Стоимость: <s>{base_price}</s> <b>{final_price}</b> (за {months} мес)\n🎁 Скидка {discount}%: -{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "Стоимость: {price} (за {months} мес)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Подтверждение подключения модема</b>\n\n{price_text}\n\nПри подключении модема:\n• К подписке добавится дополнительное устройство\n• Ежемесячная плата увеличится на {monthly_price}\n\nПодтвердить подключение?",
"ADMIN_USER_RESTRICTIONS": "⚠️ Ограничить",
"USER_RESTRICTION_TOPUP_BLOCKED": "🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.",
"USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 <b>Покупка/продление подписки ограничено</b>\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.",
"USER_RESTRICTION_APPEAL_BUTTON": "🆘 Обжаловать",
"PAUSE_DAILY_BUTTON": "⏸️ Приостановить подписку",
"RESUME_DAILY_BUTTON": "▶️ Возобновить подписку",
"DAILY_SWITCH_WARNING": "⚠️ <b>Внимание!</b> У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!",
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Подписка возобновлена!</b>\n\nВаш суточный тариф «{tariff_name}» возобновлён после пополнения баланса.\n\n💳 Списано: {amount}\n💰 Остаток: {balance}",
"WEBHOOK_SUB_EXPIRED": "❌ <b>Подписка{tariff_label} истекла</b>\n\nВаша подписка завершена. Продлите подписку, чтобы восстановить доступ к VPN.",
"WEBHOOK_SUB_DISABLED": "🚫 <b>Подписка{tariff_label} отключена</b>\n\nВаша подписка была отключена администратором.",
"WEBHOOK_SUB_ENABLED": "✅ <b>Подписка{tariff_label} активирована</b>\n\nВаша подписка снова активна. Приятного использования!",
@@ -1780,5 +1777,7 @@
"WEBHOOK_DEVICE_ADDED": "📱 <b>Новое устройство</b>\n\nК подписке{tariff_label} подключено новое устройство: <code>{device}</code>",
"WEBHOOK_DEVICE_DELETED": "📱 <b>Устройство удалено</b>\n\nУстройство отключено от подписки{tariff_label}: <code>{device}</code>",
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Обнаружен торрент</b>\n\nВ вашем подключении{tariff_label} обнаружен торрент-трафик. Использование торрентов может привести к ограничению подписки.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть"
}
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Предупреждение о трафике</b>\n\nИспользовано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш лимит трафика почти исчерпан.",
"LOW_BALANCE_ALERT": "⚠️ <b>Низкий баланс</b>\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно."
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -85,6 +85,9 @@ class DisplayNameRestrictionMiddleware(BaseMiddleware):
if not user or user.is_bot:
return await handler(event, data)
if not settings.DISPLAY_NAME_RESTRICTION_ENABLED:
return await handler(event, data)
display_name = self._build_display_name(user)
username = user.username or ''
+20 -3
View File
@@ -62,6 +62,7 @@ class BroadcastConfig:
media: BroadcastMediaConfig | None = None
initiator_name: str | None = None
custom_buttons: list[dict] | None = None
category: str = 'system' # system|news|promo
@dataclass
@@ -160,7 +161,7 @@ class BroadcastService:
await session.commit()
# _fetch_recipients теперь возвращает list[int] (telegram_id), а не ORM-объекты
recipient_ids: list[int] = await self._fetch_recipients(config.target)
recipient_ids: list[int] = await self._fetch_recipients(config.target, config.category)
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
@@ -226,8 +227,13 @@ class BroadcastService:
logger.exception('Критическая ошибка при выполнении рассылки', broadcast_id=broadcast_id, exc=exc)
await self._mark_failed(broadcast_id, sent_count, failed_count, blocked_count)
async def _fetch_recipients(self, target: str) -> list[int]:
"""Загружает получателей и возвращает список telegram_id (скаляры, не ORM-объекты)."""
async def _fetch_recipients(self, target: str, category: str = 'system') -> list[int]:
"""Загружает получателей и возвращает список telegram_id (скаляры, не ORM-объекты).
Filters out users who disabled the given broadcast category in their
notification preferences (news_enabled, promo_offers_enabled).
Category 'system' is never filtered system notifications reach everyone.
"""
async with AsyncSessionLocal() as session:
if target.startswith('custom_'):
criteria = target[len('custom_') :]
@@ -235,6 +241,17 @@ class BroadcastService:
else:
users_orm = await get_target_users(session, target)
# Filter by user notification preferences based on broadcast category
if category == 'news':
from app.utils.notification_prefs import is_news_enabled
users_orm = [u for u in users_orm if is_news_enabled(u)]
elif category == 'promo':
from app.utils.notification_prefs import is_promo_offers_enabled
users_orm = [u for u in users_orm if is_promo_offers_enabled(u)]
# category == 'system' → no filtering, sent to everyone
# Извлекаем telegram_id сразу, пока сессия жива.
# После выхода из блока ORM-объекты станут detached.
return [u.telegram_id for u in users_orm if u.telegram_id is not None]
@@ -264,6 +264,14 @@ class DailySubscriptionService:
logger.warning('Не удалось синхронизировать сквады после создания', error=patch_err)
except Exception as e:
logger.warning('Не удалось обновить Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update' if _has_panel_user else 'create',
)
# Отправляем уведомление администраторам
try:
@@ -539,6 +547,14 @@ class DailySubscriptionService:
await subscription_service.update_remnawave_user(db, subscription)
except Exception as e:
logger.warning('Не удалось синхронизировать с RemnaWave после сброса трафика', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
# Уведомляем пользователя
if self._bot and subscription.user_id:
+263 -7
View File
@@ -245,7 +245,10 @@ class MonitoringService:
await self._check_trial_expiring_soon(db)
await self._check_trial_channel_subscriptions(db)
await self._check_expired_subscription_followups(db)
await self._check_traffic_warnings(db)
await self._check_low_balance_alerts(db)
await self._retry_stuck_guest_purchases(db)
await self._cleanup_expired_refresh_tokens(db)
await self._cleanup_inactive_users(db)
await self._sync_with_remnawave(db)
@@ -517,11 +520,25 @@ class MonitoringService:
users_with_cards = await get_user_ids_with_active_payment_methods(db, autopay_user_ids)
from app.utils.notification_prefs import (
get_subscription_expiry_days,
is_subscription_expiry_enabled,
)
for subscription in expiring_subscriptions:
user = await get_user_by_id(db, subscription.user_id)
if not user:
continue
# Respect user notification preferences
if not is_subscription_expiry_enabled(user):
continue
# Check if user's preferred days threshold matches this check
user_expiry_days = get_subscription_expiry_days(user)
if days > user_expiry_days:
continue
# Use user.id + subscription.id for key to support multiple subscriptions per user
sub_key = f'user_{user.id}_sub_{subscription.id}_today'
user_identifier = user.telegram_id or f'email:{user.id}'
@@ -884,18 +901,24 @@ class MonitoringService:
)
try:
panel_uuid_restore = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else user.remnawave_uuid
)
if panel_uuid_restore:
await self.subscription_service.enable_remnawave_user(panel_uuid_restore)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(user, 'remnawave_uuid', None)
if _should_create:
# create_remnawave_user calls db.commit() internally --
# flush accumulated batch state first to preserve atomicity.
await batch_db.commit()
await self.subscription_service.create_remnawave_user(batch_db, subscription)
else:
_enable_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled()
else user.remnawave_uuid
)
if _enable_uuid:
await self.subscription_service.enable_remnawave_user(_enable_uuid)
except Exception as api_error:
logger.error(
'Failed to update RemnaWave user',
@@ -1960,6 +1983,239 @@ class MonitoringService:
except Exception:
logger.error('Error retrying stuck PENDING_ACTIVATION guest purchases', exc_info=True)
async def _check_traffic_warnings(self, db: AsyncSession):
"""Check subscriptions approaching traffic limit and notify users."""
if not self.bot:
return
try:
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database.models import Subscription
from app.utils.notification_prefs import get_traffic_warning_percent, is_traffic_warning_enabled
# Get active subscriptions with traffic limits (not unlimited)
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.where(
Subscription.status.in_(['active', 'trial']),
Subscription.traffic_limit_gb > 0,
)
)
subscriptions = result.scalars().all()
sent_count = 0
for subscription in subscriptions:
user = subscription.user
if not user or not user.telegram_id:
continue
if not is_traffic_warning_enabled(user):
continue
traffic_limit = subscription.traffic_limit_gb or 0
traffic_used = subscription.traffic_used_gb or 0.0
if traffic_limit <= 0:
continue
current_percent = (traffic_used / traffic_limit) * 100
user_threshold = get_traffic_warning_percent(user)
if current_percent < user_threshold:
continue
# Rate-limit: 1 notification per subscription per 24 hours
cache_key_str = f'traffic_warn:{subscription.id}'
try:
already_sent = await cache.get(cache_key_str)
if already_sent:
continue
except Exception:
pass
try:
language = getattr(user, 'language', 'ru') or 'ru'
texts = get_texts(language)
message = texts.get(
'TRAFFIC_WARNING_ALERT',
'⚠️ <b>Предупреждение о трафике</b>\n\n'
'Использовано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\n'
'Ваш лимит трафика почти исчерпан.',
)
message = message.format(
used=traffic_used,
limit=traffic_limit,
percent=current_percent,
)
await self.bot.send_message(
user.telegram_id,
message,
parse_mode='HTML',
)
try:
await cache.set(cache_key_str, '1', expire=86400)
except Exception:
pass
sent_count += 1
except Exception as send_error:
logger.debug(
'Failed to send traffic warning',
user_id=user.id,
subscription_id=subscription.id,
error=send_error,
)
if sent_count > 0:
logger.info('Traffic warnings sent', sent_count=sent_count)
except Exception as error:
logger.error('Error checking traffic warnings', error=error)
async def _check_low_balance_alerts(self, db: AsyncSession):
"""Check users with autopay enabled who have low balance and notify them.
Guards:
- Disabled by default; users opt-in via cabinet notification settings
- Only alerts when subscription expires within LOW_BALANCE_ALERT_EXPIRY_DAYS (default 3)
- Quiet hours: skips sending between 22:00 and 09:00 server time
- Rate-limited: max 1 alert per 24 hours per user
"""
if not self.bot:
return
try:
from datetime import UTC, datetime, timedelta
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
from sqlalchemy import select
from app.database.models import Subscription, User
from app.utils.notification_prefs import get_balance_low_threshold, is_balance_low_enabled
# Quiet hours: don't disturb users at night (22:00-09:00 UTC)
current_hour = datetime.now(UTC).hour
if current_hour >= 22 or current_hour < 9:
return
# Only alert for subscriptions expiring soon (default 3 days)
expiry_days = getattr(settings, 'LOW_BALANCE_ALERT_EXPIRY_DAYS', 3)
expiry_threshold = datetime.now(UTC) + timedelta(days=expiry_days)
result = await db.execute(
select(User)
.join(Subscription, Subscription.user_id == User.id)
.where(
Subscription.status.in_(['active', 'trial']),
Subscription.autopay_enabled.is_(True),
Subscription.end_date.isnot(None),
Subscription.end_date <= expiry_threshold,
User.telegram_id.isnot(None),
)
.distinct()
)
users = result.scalars().all()
sent_count = 0
for user in users:
if not is_balance_low_enabled(user):
continue
threshold = get_balance_low_threshold(user)
balance = int(getattr(user, 'balance_kopeks', 0) or 0)
if balance >= threshold:
continue
# Rate-limit via Redis: max 1 notification per 24 hours per user
cache_key_str = f'low_balance_alert:{user.id}'
try:
already_sent = await cache.get(cache_key_str)
if already_sent:
continue
except Exception:
pass
try:
language = getattr(user, 'language', 'ru') or 'ru'
texts = get_texts(language)
threshold_rub = threshold / 100
balance_rub = balance / 100
message = texts.get(
'LOW_BALANCE_ALERT',
'⚠️ <b>Низкий баланс</b>\n\n'
'Ваш баланс: {balance}\n'
'Порог уведомления: {threshold}\n\n'
'Пополните баланс, чтобы автопродление подписки прошло успешно.',
)
message = message.format(
balance=f'{balance_rub:.0f}',
threshold=f'{threshold_rub:.0f}',
)
# Build inline keyboard with cabinet top-up button
keyboard = None
miniapp_url = settings.get_main_menu_miniapp_url()
if miniapp_url:
topup_label = texts.get('LOW_BALANCE_TOPUP_BUTTON', '💳 Пополнить баланс')
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=topup_label,
web_app=WebAppInfo(url=miniapp_url),
)
]
]
)
await self.bot.send_message(
user.telegram_id,
message,
parse_mode='HTML',
reply_markup=keyboard,
)
# Mark as sent for 24 hours
try:
await cache.set(cache_key_str, '1', expire=86400)
except Exception:
pass
sent_count += 1
except Exception as send_error:
logger.debug('Failed to send low balance alert', user_id=user.id, error=send_error)
if sent_count > 0:
logger.info('Low balance alerts sent', sent_count=sent_count)
except Exception as error:
logger.error('Error checking low balance alerts', error=error)
async def _cleanup_expired_refresh_tokens(self, db: AsyncSession):
"""Delete expired and revoked refresh tokens to prevent table bloat."""
try:
from sqlalchemy import delete
from app.database.models import CabinetRefreshToken
now = datetime.now(UTC)
# Delete tokens that are either expired or revoked more than 24h ago
stmt = delete(CabinetRefreshToken).where(
(CabinetRefreshToken.expires_at < now) | (CabinetRefreshToken.revoked_at < now - timedelta(hours=24))
)
result = await db.execute(stmt)
deleted = result.rowcount
if deleted > 0:
await db.commit()
logger.info('Cleaned up expired/revoked refresh tokens', deleted_count=deleted)
except Exception as error:
logger.error('Error cleaning up refresh tokens', error=error)
try:
await db.rollback()
except Exception:
pass
async def _cleanup_inactive_users(self, db: AsyncSession):
try:
now = datetime.now(UTC)
+13 -4
View File
@@ -156,11 +156,20 @@ class NalogoQueueService:
payment_id = receipt_data.get('payment_id', 'unknown')
amount = receipt_data.get('amount', 0)
# Логируем количество попыток (чек никогда не удаляется из очереди)
if attempts >= 10:
logger.warning(
'Чек уже много попыток, продолжаем пытаться...', payment_id=payment_id, attempts=attempts
# Проверяем лимит попыток
if attempts >= self._max_attempts:
logger.error(
'Чек превысил максимальное количество попыток, удалён из очереди',
payment_id=payment_id,
attempts=attempts,
max_attempts=self._max_attempts,
)
# Удаляем метку "в очереди" — чек больше не будет обрабатываться
if payment_id and payment_id != 'unknown':
queued_key = f'nalogo:queued:{payment_id}'
await cache.delete(queued_key)
failed += 1
continue
# Пытаемся отправить чек
try:
+4
View File
@@ -12,8 +12,10 @@ from .heleket import HeleketPaymentMixin
from .kassa_ai import KassaAiPaymentMixin
from .mulenpay import MulenPayPaymentMixin
from .pal24 import Pal24PaymentMixin
from .paypear import PayPearPaymentMixin
from .platega import PlategaPaymentMixin
from .riopay import RioPayPaymentMixin
from .rollypay import RollyPayPaymentMixin
from .severpay import SeverPayPaymentMixin
from .stars import TelegramStarsMixin
from .tribute import TributePaymentMixin
@@ -29,9 +31,11 @@ __all__ = [
'KassaAiPaymentMixin',
'MulenPayPaymentMixin',
'Pal24PaymentMixin',
'PayPearPaymentMixin',
'PaymentCommonMixin',
'PlategaPaymentMixin',
'RioPayPaymentMixin',
'RollyPayPaymentMixin',
'SeverPayPaymentMixin',
'TelegramStarsMixin',
'TributePaymentMixin',
+624
View File
@@ -0,0 +1,624 @@
"""Mixin для интеграции с PayPear (paypear.ru)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from importlib import import_module
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.paypear_service import paypear_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг статусов PayPear -> internal
PAYPEAR_STATUS_MAP: dict[str, tuple[str, bool]] = {
'NEW': ('pending', False),
'PROCESS': ('processing', False),
'CONFIRMED': ('success', True),
'CANCELED': ('canceled', False),
'REFUNDED': ('refunded', False),
'EXPIRED': ('expired', False),
}
class PayPearPaymentMixin:
"""Mixin для работы с платежами PayPear."""
async def create_paypear_payment(
self,
db: AsyncSession,
*,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
return_url: str | None = None,
) -> dict[str, Any] | None:
"""
Создает платеж PayPear.
Returns:
Словарь с данными платежа или None при ошибке
"""
if not settings.is_paypear_enabled():
logger.error('PayPear не настроен')
return None
# Валидация лимитов
if amount_kopeks < settings.PAYPEAR_MIN_AMOUNT_KOPEKS:
logger.warning(
'PayPear: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
PAYPEAR_MIN_AMOUNT_KOPEKS=settings.PAYPEAR_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.PAYPEAR_MAX_AMOUNT_KOPEKS:
logger.warning(
'PayPear: сумма больше максимальной',
amount_kopeks=amount_kopeks,
PAYPEAR_MAX_AMOUNT_KOPEKS=settings.PAYPEAR_MAX_AMOUNT_KOPEKS,
)
return None
# Получаем telegram_id пользователя для order_id
payment_module = import_module('app.services.payment_service')
if user_id is not None:
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
user = None
tg_id = 'guest'
# Генерируем уникальный order_id с telegram_id для удобного поиска
order_id = f'pp{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.PAYPEAR_CURRENCY
# Метаданные
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
}
try:
payment_method_type = settings.PAYPEAR_PAYMENT_METHOD
# Используем API для создания платежа
result = await paypear_service.create_payment(
order_id=order_id,
amount_rubles=amount_rubles,
currency=currency,
payment_method_type=payment_method_type,
description=description,
return_url=return_url or settings.PAYPEAR_RETURN_URL,
metadata=metadata,
)
confirmation = result.get('confirmation', {})
payment_url = confirmation.get('url') if isinstance(confirmation, dict) else None
paypear_id = result.get('id')
if not payment_url:
logger.error('PayPear API не вернул URL платежа', result=result)
return None
logger.info(
'PayPear API: создан платеж',
order_id=order_id,
paypear_id=paypear_id,
payment_url=payment_url,
)
# Срок действия — 30 минут по умолчанию
expires_at = datetime.now(UTC) + timedelta(minutes=30)
# Сохраняем в БД
paypear_crud = import_module('app.database.crud.paypear')
local_payment = await paypear_crud.create_paypear_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method_type,
paypear_id=paypear_id,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'PayPear: создан платеж',
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
)
return {
'order_id': order_id,
'paypear_id': paypear_id,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'payment_url': payment_url,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('PayPear: ошибка создания платежа', error=e)
return None
async def process_paypear_webhook(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""
Обрабатывает webhook от PayPear.
Подпись проверяется в webserver/payments.py до вызова этого метода.
Args:
db: Сессия БД
payload: JSON тело webhook (signature проверена в webserver)
Returns:
True если платеж успешно обработан
"""
try:
event = payload.get('event')
obj = payload.get('object', {})
paypear_id = obj.get('id')
order_id = obj.get('order_id')
paypear_status = obj.get('status')
if not paypear_id or not paypear_status:
logger.warning('PayPear webhook: отсутствуют обязательные поля', payload=payload)
return False
# Определяем is_paid по event
is_confirmed = event == 'payment.confirmed'
# Ищем платеж по order_id (наш) или paypear_id
paypear_crud = import_module('app.database.crud.paypear')
payment = None
if order_id:
payment = await paypear_crud.get_paypear_payment_by_order_id(db, order_id)
if not payment and paypear_id:
payment = await paypear_crud.get_paypear_payment_by_paypear_id(db, paypear_id)
if not payment:
logger.warning(
'PayPear webhook: платеж не найден',
order_id=order_id,
paypear_id=paypear_id,
)
return False
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await paypear_crud.get_paypear_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('PayPear: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Проверка дублирования (re-check from locked row)
if payment.is_paid:
logger.info('PayPear webhook: платеж уже обработан', order_id=payment.order_id)
return True
# Маппинг статуса
status_info = PAYPEAR_STATUS_MAP.get(paypear_status, ('pending', False))
internal_status, is_paid = status_info
# Если event = payment.confirmed, принудительно считаем оплаченным
if is_confirmed:
is_paid = True
internal_status = 'success'
callback_payload = {
'paypear_id': paypear_id,
'order_id': order_id,
'status': paypear_status,
'event': event,
'amount': obj.get('amount'),
}
# Проверка суммы ДО обновления статуса
if is_paid:
amount_info = obj.get('amount', {})
if isinstance(amount_info, dict):
amount_value = amount_info.get('value')
else:
amount_value = amount_info
if amount_value is not None:
received_kopeks = round(float(amount_value) * 100)
if abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'PayPear amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await paypear_crud.update_paypear_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
paypear_id=paypear_id,
callback_payload=callback_payload,
)
return False
# Финализируем платеж если оплачен — без промежуточного commit
if is_paid:
# Inline field assignments to keep FOR UPDATE lock intact
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.paypear_id = paypear_id or payment.paypear_id
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_paypear_payment(db, payment, paypear_id=paypear_id, trigger='webhook')
# Для не-success статусов можно безопасно коммитить
payment = await paypear_crud.update_paypear_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
paypear_id=paypear_id,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('PayPear webhook: ошибка обработки', error=e)
return False
async def _finalize_paypear_payment(
self,
db: AsyncSession,
payment: Any,
*,
paypear_id: str | None,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE lock must be acquired by the caller before invoking this method.
"""
payment_module = import_module('app.services.payment_service')
paypear_crud = import_module('app.database.crud.paypear')
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'PayPear платеж уже связан с транзакцией',
order_id=payment.order_id,
transaction_id=payment.transaction_id,
trigger=trigger,
)
return True
# Read fresh metadata AFTER lock to avoid stale data
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
# --- Guest purchase flow ---
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=str(paypear_id) if paypear_id else payment.order_id,
provider_name='paypear',
)
if guest_result is not None:
return True
# Ensure paid fields are set (idempotent — caller may have already set them)
if not payment.is_paid:
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
balance_already_credited = bool(metadata.get('balance_credited'))
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error('Пользователь не найден для PayPear', user_id=payment.user_id)
return False
# Загружаем промогруппы в асинхронном контексте
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
for user_promo_group in getattr(user, 'user_promo_groups', []):
await db.refresh(user_promo_group, attribute_names=['promo_group'])
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
transaction_external_id = str(paypear_id) if paypear_id else payment.order_id
# Проверяем дупликат транзакции
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.PAYPEAR,
)
display_name = settings.get_paypear_display_name()
description = f'Пополнение через {display_name}'
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.PAYPEAR,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await paypear_crud.link_paypear_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info('PayPear платеж уже зачислил баланс ранее', order_id=payment.order_id)
return True
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
# Emit deferred side-effects after atomic commit
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.PAYPEAR,
external_id=transaction_external_id,
)
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения PayPear', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, 'bot', None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления PayPear', error=error)
if getattr(self, 'bot', None) and user.telegram_id:
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
'\u2705 <b>Пополнение успешно!</b>\n\n'
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
f'\U0001f4b3 Способ: {display_name}\n'
f'\U0001f194 Транзакция: {transaction.id}\n\n'
'Баланс пополнен автоматически!'
),
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.error('Ошибка отправки уведомления пользователю PayPear', error=error)
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
except Exception as error:
logger.error(
'Ошибка при работе с сохраненной корзиной для пользователя',
user_id=payment.user_id,
error=error,
exc_info=True,
)
metadata['balance_change'] = {
'old_balance': old_balance,
'new_balance': user.balance_kopeks,
'credited_at': datetime.now(UTC).isoformat(),
}
metadata['balance_credited'] = True
payment.metadata_json = metadata
await db.commit()
logger.info(
'Обработан PayPear платеж',
order_id=payment.order_id,
user_id=payment.user_id,
trigger=trigger,
)
return True
async def check_paypear_payment_status(
self,
db: AsyncSession,
order_id: str,
) -> dict[str, Any] | None:
"""Проверяет статус платежа через API."""
try:
paypear_crud = import_module('app.database.crud.paypear')
payment = await paypear_crud.get_paypear_payment_by_order_id(db, order_id)
if not payment:
logger.warning('PayPear payment not found', order_id=order_id)
return None
if payment.is_paid:
return {
'payment': payment,
'status': 'success',
'is_paid': True,
}
# Проверяем через API по paypear_id
if payment.paypear_id:
try:
order_data = await paypear_service.get_payment(payment.paypear_id)
paypear_status = order_data.get('status')
if paypear_status:
status_info = PAYPEAR_STATUS_MAP.get(paypear_status, ('pending', False))
internal_status, is_paid = status_info
if is_paid:
# Проверка суммы
amount_info = order_data.get('amount', {})
api_amount = amount_info.get('value') if isinstance(amount_info, dict) else amount_info
if api_amount is not None:
received_kopeks = round(float(api_amount) * 100)
if abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'PayPear amount mismatch (API check)',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await paypear_crud.update_paypear_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
paypear_id=payment.paypear_id,
callback_payload={
'check_source': 'api',
'paypear_order_data': order_data,
},
)
return {
'payment': payment,
'status': 'amount_mismatch',
'is_paid': False,
}
# Acquire FOR UPDATE lock before finalization
locked = await paypear_crud.get_paypear_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('PayPear: не удалось заблокировать платёж', payment_id=payment.id)
return None
payment = locked
if payment.is_paid:
logger.info('PayPear платеж уже обработан (api_check)', order_id=payment.order_id)
return {
'payment': payment,
'status': 'success',
'is_paid': True,
}
logger.info('PayPear payment confirmed via API', order_id=payment.order_id)
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = {
'check_source': 'api',
'paypear_order_data': order_data,
}
payment.updated_at = datetime.now(UTC)
await db.flush()
await self._finalize_paypear_payment(
db,
payment,
paypear_id=payment.paypear_id,
trigger='api_check',
)
elif internal_status != payment.status:
# Обновляем статус если изменился
payment = await paypear_crud.update_paypear_payment_status(
db=db,
payment=payment,
status=internal_status,
)
except Exception as e:
logger.error('Error checking PayPear payment status via API', error=e)
return {
'payment': payment,
'status': payment.status or 'pending',
'is_paid': payment.is_paid,
}
except Exception as e:
logger.exception('PayPear: ошибка проверки статуса', error=e)
return None
+629
View File
@@ -0,0 +1,629 @@
"""Mixin для интеграции с RollyPay (rollypay.io)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from importlib import import_module
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.rollypay_service import rollypay_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг статусов RollyPay -> internal
ROLLYPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
'created': ('pending', False),
'processing': ('processing', False),
'paid': ('success', True),
'expired': ('expired', False),
'canceled': ('canceled', False),
'chargeback': ('chargeback', False),
}
class RollyPayPaymentMixin:
"""Mixin для работы с платежами RollyPay."""
async def create_rollypay_payment(
self,
db: AsyncSession,
*,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
payment_method_type: str | None = None,
return_url: str | None = None,
) -> dict[str, Any] | None:
"""
Создает платеж RollyPay.
Returns:
Словарь с данными платежа или None при ошибке
"""
if not settings.is_rollypay_enabled():
logger.error('RollyPay не настроен')
return None
# Валидация лимитов
if amount_kopeks < settings.ROLLYPAY_MIN_AMOUNT_KOPEKS:
logger.warning(
'RollyPay: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
ROLLYPAY_MIN_AMOUNT_KOPEKS=settings.ROLLYPAY_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.ROLLYPAY_MAX_AMOUNT_KOPEKS:
logger.warning(
'RollyPay: сумма больше максимальной',
amount_kopeks=amount_kopeks,
ROLLYPAY_MAX_AMOUNT_KOPEKS=settings.ROLLYPAY_MAX_AMOUNT_KOPEKS,
)
return None
# Получаем telegram_id пользователя для order_id
payment_module = import_module('app.services.payment_service')
if user_id is not None:
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
user = None
tg_id = 'guest'
# Генерируем уникальный order_id с telegram_id для удобного поиска
order_id = f'rp{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
amount_value = f'{amount_rubles:.2f}'
currency = settings.ROLLYPAY_CURRENCY
# Метаданные
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
}
try:
# Используем API для создания платежа
result = await rollypay_service.create_payment(
amount_value=amount_value,
currency=currency,
order_id=order_id,
payment_method=payment_method_type,
description=description,
redirect_url=return_url or settings.ROLLYPAY_RETURN_URL,
customer_id=str(tg_id),
metadata=metadata,
)
payment_url = result.get('pay_url')
rollypay_payment_id = result.get('payment_id')
if not payment_url:
logger.error('RollyPay API не вернул URL платежа', result=result)
return None
logger.info(
'RollyPay API: создан платеж',
order_id=order_id,
rollypay_payment_id=rollypay_payment_id,
payment_url=payment_url,
)
# Срок действия из expires_at ответа или 30 минут по умолчанию
expires_at_str = result.get('expires_at')
if expires_at_str:
try:
expires_at = datetime.fromisoformat(expires_at_str)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
except (ValueError, TypeError):
expires_at = datetime.now(UTC) + timedelta(minutes=30)
else:
expires_at = datetime.now(UTC) + timedelta(minutes=30)
# Сохраняем в БД
rollypay_crud = import_module('app.database.crud.rollypay')
local_payment = await rollypay_crud.create_rollypay_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method_type or 'sbp',
rollypay_payment_id=rollypay_payment_id,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'RollyPay: создан платеж',
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
)
return {
'order_id': order_id,
'rollypay_payment_id': rollypay_payment_id,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'payment_url': payment_url,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('RollyPay: ошибка создания платежа', error=e)
return None
async def process_rollypay_webhook(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""
Обрабатывает webhook от RollyPay.
Подпись проверяется в webserver/payments.py до вызова этого метода.
Args:
db: Сессия БД
payload: JSON тело webhook (signature проверена в webserver)
Returns:
True если платеж успешно обработан
"""
try:
event_type = payload.get('event_type')
rollypay_payment_id = payload.get('payment_id')
order_id = payload.get('order_id')
rollypay_status = payload.get('status')
if not rollypay_payment_id or not rollypay_status:
logger.warning('RollyPay webhook: отсутствуют обязательные поля', payload=payload)
return False
# Определяем is_paid по event
is_confirmed = event_type == 'payment.paid'
# Ищем платеж по order_id (наш) или rollypay_payment_id
rollypay_crud = import_module('app.database.crud.rollypay')
payment = None
if order_id:
payment = await rollypay_crud.get_rollypay_payment_by_order_id(db, order_id)
if not payment and rollypay_payment_id:
payment = await rollypay_crud.get_rollypay_payment_by_rollypay_id(db, rollypay_payment_id)
if not payment:
logger.warning(
'RollyPay webhook: платеж не найден',
order_id=order_id,
rollypay_payment_id=rollypay_payment_id,
)
return False
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await rollypay_crud.get_rollypay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('RollyPay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Проверка дублирования (re-check from locked row)
if payment.is_paid:
logger.info('RollyPay webhook: платеж уже обработан', order_id=payment.order_id)
return True
# Маппинг статуса
status_info = ROLLYPAY_STATUS_MAP.get(rollypay_status, ('pending', False))
internal_status, is_paid = status_info
# Если event = payment.paid, принудительно считаем оплаченным
if is_confirmed:
is_paid = True
internal_status = 'success'
callback_payload = {
'rollypay_payment_id': rollypay_payment_id,
'order_id': order_id,
'status': rollypay_status,
'event_type': event_type,
'amount': payload.get('amount'),
'currency': payload.get('currency'),
}
# Проверка суммы ДО обновления статуса
if is_paid:
amount_value = payload.get('amount')
if amount_value is not None:
received_kopeks = round(float(amount_value) * 100)
if abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'RollyPay amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await rollypay_crud.update_rollypay_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
rollypay_payment_id=rollypay_payment_id,
callback_payload=callback_payload,
)
return False
# Финализируем платеж если оплачен — без промежуточного commit
if is_paid:
# Inline field assignments to keep FOR UPDATE lock intact
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.rollypay_payment_id = rollypay_payment_id or payment.rollypay_payment_id
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_rollypay_payment(
db, payment, rollypay_payment_id=rollypay_payment_id, trigger='webhook'
)
# Для не-success статусов можно безопасно коммитить
payment = await rollypay_crud.update_rollypay_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
rollypay_payment_id=rollypay_payment_id,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('RollyPay webhook: ошибка обработки', error=e)
return False
async def _finalize_rollypay_payment(
self,
db: AsyncSession,
payment: Any,
*,
rollypay_payment_id: str | None,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE lock must be acquired by the caller before invoking this method.
"""
payment_module = import_module('app.services.payment_service')
rollypay_crud = import_module('app.database.crud.rollypay')
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'RollyPay платеж уже связан с транзакцией',
order_id=payment.order_id,
transaction_id=payment.transaction_id,
trigger=trigger,
)
return True
# Read fresh metadata AFTER lock to avoid stale data
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
# --- Guest purchase flow ---
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=str(rollypay_payment_id) if rollypay_payment_id else payment.order_id,
provider_name='rollypay',
)
if guest_result is not None:
return True
# Ensure paid fields are set (idempotent — caller may have already set them)
if not payment.is_paid:
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
balance_already_credited = bool(metadata.get('balance_credited'))
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error('Пользователь не найден для RollyPay', user_id=payment.user_id)
return False
# Загружаем промогруппы в асинхронном контексте
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
for user_promo_group in getattr(user, 'user_promo_groups', []):
await db.refresh(user_promo_group, attribute_names=['promo_group'])
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
transaction_external_id = str(rollypay_payment_id) if rollypay_payment_id else payment.order_id
# Проверяем дупликат транзакции
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.ROLLYPAY,
)
display_name = settings.get_rollypay_display_name()
description = f'Пополнение через {display_name}'
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.ROLLYPAY,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await rollypay_crud.link_rollypay_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info('RollyPay платеж уже зачислил баланс ранее', order_id=payment.order_id)
return True
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
# Emit deferred side-effects after atomic commit
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.ROLLYPAY,
external_id=transaction_external_id,
)
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения RollyPay', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, 'bot', None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления RollyPay', error=error)
if getattr(self, 'bot', None) and user.telegram_id:
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
'\u2705 <b>Пополнение успешно!</b>\n\n'
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
f'\U0001f4b3 Способ: {display_name}\n'
f'\U0001f194 Транзакция: {transaction.id}\n\n'
'Баланс пополнен автоматически!'
),
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.error('Ошибка отправки уведомления пользователю RollyPay', error=error)
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
except Exception as error:
logger.error(
'Ошибка при работе с сохраненной корзиной для пользователя',
user_id=payment.user_id,
error=error,
exc_info=True,
)
metadata['balance_change'] = {
'old_balance': old_balance,
'new_balance': user.balance_kopeks,
'credited_at': datetime.now(UTC).isoformat(),
}
metadata['balance_credited'] = True
payment.metadata_json = metadata
await db.commit()
logger.info(
'Обработан RollyPay платеж',
order_id=payment.order_id,
user_id=payment.user_id,
trigger=trigger,
)
return True
async def check_rollypay_payment_status(
self,
db: AsyncSession,
order_id: str,
) -> dict[str, Any] | None:
"""Проверяет статус платежа через API."""
try:
rollypay_crud = import_module('app.database.crud.rollypay')
payment = await rollypay_crud.get_rollypay_payment_by_order_id(db, order_id)
if not payment:
logger.warning('RollyPay payment not found', order_id=order_id)
return None
if payment.is_paid:
return {
'payment': payment,
'status': 'success',
'is_paid': True,
}
# Проверяем через API по rollypay_payment_id
if payment.rollypay_payment_id:
try:
order_data = await rollypay_service.get_payment(payment.rollypay_payment_id)
rollypay_status = order_data.get('status')
if rollypay_status:
status_info = ROLLYPAY_STATUS_MAP.get(rollypay_status, ('pending', False))
internal_status, is_paid = status_info
if is_paid:
# Проверка суммы
api_amount = order_data.get('amount')
if api_amount is not None:
received_kopeks = round(float(api_amount) * 100)
if abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'RollyPay amount mismatch (API check)',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await rollypay_crud.update_rollypay_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
rollypay_payment_id=payment.rollypay_payment_id,
callback_payload={
'check_source': 'api',
'rollypay_order_data': order_data,
},
)
return {
'payment': payment,
'status': 'amount_mismatch',
'is_paid': False,
}
# Acquire FOR UPDATE lock before finalization
locked = await rollypay_crud.get_rollypay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('RollyPay: не удалось заблокировать платёж', payment_id=payment.id)
return None
payment = locked
if payment.is_paid:
logger.info('RollyPay платеж уже обработан (api_check)', order_id=payment.order_id)
return {
'payment': payment,
'status': 'success',
'is_paid': True,
}
logger.info('RollyPay payment confirmed via API', order_id=payment.order_id)
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = {
'check_source': 'api',
'rollypay_order_data': order_data,
}
payment.updated_at = datetime.now(UTC)
await db.flush()
await self._finalize_rollypay_payment(
db,
payment,
rollypay_payment_id=payment.rollypay_payment_id,
trigger='api_check',
)
elif internal_status != payment.status:
# Обновляем статус если изменился
payment = await rollypay_crud.update_rollypay_payment_status(
db=db,
payment=payment,
status=internal_status,
)
except Exception as e:
logger.error('Error checking RollyPay payment status via API', error=e)
return {
'payment': payment,
'status': payment.status or 'pending',
'is_paid': payment.is_paid,
}
except Exception as e:
logger.exception('RollyPay: ошибка проверки статуса', error=e)
return None
+7
View File
@@ -286,6 +286,13 @@ class TelegramStarsMixin:
sync_error=sync_error,
exc_info=True,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
period_display = period_days
if not period_display and getattr(subscription, 'start_date', None) and getattr(subscription, 'end_date', None):
+14
View File
@@ -680,6 +680,13 @@ class YooKassaPaymentMixin:
await subscription_service.create_remnawave_user(db, subscription)
except Exception as rw_error:
logger.error('Ошибка создания RemnaWave для триала', rw_error=rw_error)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
# Уведомление админам
if getattr(self, 'bot', None):
@@ -991,6 +998,13 @@ class YooKassaPaymentMixin:
sync_error=sync_error,
exc_info=True,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
# Отправляем уведомление пользователю об активации подписки (только Telegram)
if getattr(self, 'bot', None) and user.telegram_id:
@@ -146,6 +146,29 @@ def _get_method_defaults() -> dict:
'default_max': settings.SEVERPAY_MAX_AMOUNT_KOPEKS,
'available_sub_options': None,
},
'paypear': {
'default_display_name': settings.get_paypear_display_name(),
'is_configured': settings.is_paypear_enabled(),
'default_min': settings.PAYPEAR_MIN_AMOUNT_KOPEKS,
'default_max': settings.PAYPEAR_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'bank_card', 'name': 'Карта'},
{'id': 'sbp', 'name': 'СБП'},
{'id': 'sberpay', 'name': 'SberPay'},
{'id': 'tpay', 'name': 'T-Pay'},
],
},
'rollypay': {
'default_display_name': settings.get_rollypay_display_name(),
'is_configured': settings.is_rollypay_enabled(),
'default_min': settings.ROLLYPAY_MIN_AMOUNT_KOPEKS,
'default_max': settings.ROLLYPAY_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'sbp', 'name': 'СБП'},
{'id': 'card', 'name': 'Карта'},
{'id': 'crypto', 'name': 'Криптовалюта'},
],
},
}
@@ -188,6 +211,8 @@ DEFAULT_METHOD_ORDER = [
'kassa_ai',
'riopay',
'severpay',
'paypear',
'rollypay',
]
+61
View File
@@ -33,7 +33,9 @@ from app.services.payment import (
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
from app.services.payment.freekassa import FreekassaPaymentMixin
from app.services.payment.kassa_ai import KassaAiPaymentMixin
from app.services.payment.paypear import PayPearPaymentMixin
from app.services.payment.riopay import RioPayPaymentMixin
from app.services.payment.rollypay import RollyPayPaymentMixin
from app.services.payment.severpay import SeverPayPaymentMixin
from app.services.platega_service import PlategaService
from app.services.wata_service import WataService
@@ -332,6 +334,41 @@ async def link_severpay_payment_to_transaction(*args, **kwargs):
return await severpay_crud.link_severpay_payment_to_transaction(*args, **kwargs)
async def create_paypear_payment(*args, **kwargs):
paypear_crud = import_module('app.database.crud.paypear')
return await paypear_crud.create_paypear_payment(*args, **kwargs)
async def get_paypear_payment_by_order_id(*args, **kwargs):
paypear_crud = import_module('app.database.crud.paypear')
return await paypear_crud.get_paypear_payment_by_order_id(*args, **kwargs)
async def get_paypear_payment_by_paypear_id(*args, **kwargs):
paypear_crud = import_module('app.database.crud.paypear')
return await paypear_crud.get_paypear_payment_by_paypear_id(*args, **kwargs)
async def get_paypear_payment_by_id(*args, **kwargs):
paypear_crud = import_module('app.database.crud.paypear')
return await paypear_crud.get_paypear_payment_by_id(*args, **kwargs)
async def get_paypear_payment_by_id_for_update(*args, **kwargs):
paypear_crud = import_module('app.database.crud.paypear')
return await paypear_crud.get_paypear_payment_by_id_for_update(*args, **kwargs)
async def update_paypear_payment_status(*args, **kwargs):
paypear_crud = import_module('app.database.crud.paypear')
return await paypear_crud.update_paypear_payment_status(*args, **kwargs)
async def link_paypear_payment_to_transaction(*args, **kwargs):
paypear_crud = import_module('app.database.crud.paypear')
return await paypear_crud.link_paypear_payment_to_transaction(*args, **kwargs)
# Mapping from model_name to getter function name for providers
# where it differs from the standard get_{model_name}_payment_by_id pattern.
_GETTER_OVERRIDES: dict[str, str] = {
@@ -355,6 +392,8 @@ class PaymentService(
KassaAiPaymentMixin,
RioPayPaymentMixin,
SeverPayPaymentMixin,
PayPearPaymentMixin,
RollyPayPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
@@ -774,6 +813,28 @@ class PaymentService(
}
return None
# --- PayPear ----------------------------------------------------------
if payment_method == 'paypear':
if not settings.is_paypear_enabled():
logger.warning('PayPear is not enabled, cannot create guest payment')
return None
result = await self.create_paypear_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
return_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'paypear')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('paypear_id') or result.get('order_id'),
'provider': 'paypear',
}
return None
# --- Telegram Stars ---------------------------------------------------
if payment_method == 'telegram_stars':
if not settings.TELEGRAM_STARS_ENABLED:
+235
View File
@@ -0,0 +1,235 @@
"""Сервис для работы с API PayPear (paypear.ru)."""
import hashlib
import hmac
import uuid
from base64 import b64encode
from typing import Any
import aiohttp
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
API_BASE_URL = 'https://api.paypear.ru/v1'
class PayPearAPIError(Exception):
"""Ошибка API PayPear."""
def __init__(self, status_code: int, message: str):
self.status_code = status_code
self.message = message
super().__init__(f'PayPear API error ({status_code}): {message}')
class PayPearService:
"""Сервис для работы с API PayPear."""
def __init__(self):
self._session: aiohttp.ClientSession | None = None
@property
def shop_id(self) -> str:
return settings.PAYPEAR_SHOP_ID or ''
@property
def secret_key(self) -> str:
return settings.PAYPEAR_SECRET_KEY or ''
def _basic_auth_header(self) -> str:
"""Генерирует HTTP Basic Auth заголовок."""
credentials = f'{self.shop_id}:{self.secret_key}'
encoded = b64encode(credentials.encode('utf-8')).decode('utf-8')
return f'Basic {encoded}'
async def _get_session(self) -> aiohttp.ClientSession:
"""Возвращает переиспользуемую HTTP-сессию."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
)
return self._session
async def close(self) -> None:
"""Закрывает HTTP-сессию."""
if self._session and not self._session.closed:
await self._session.close()
self._session = None
async def create_payment(
self,
*,
order_id: str,
amount_rubles: float,
currency: str = 'RUB',
payment_method_type: str = 'sbp',
description: str = '',
return_url: str | None = None,
webhook_url: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Создает платеж через API PayPear.
POST /v1/payment/
"""
idempotence_key = str(uuid.uuid4())
payload: dict[str, Any] = {
'amount': {
'value': f'{amount_rubles:.2f}',
'currency': currency,
},
'order_id': order_id,
'payment_method_data': {
'type': payment_method_type,
},
}
if description:
payload['description'] = description
if return_url:
payload['confirmation'] = {
'type': 'redirect',
'return_url': return_url,
}
if webhook_url:
payload['webhook_url'] = webhook_url
if metadata:
payload['metadata'] = metadata
logger.info(
'PayPear API create_payment',
order_id=order_id,
amount_rubles=amount_rubles,
currency=currency,
payment_method_type=payment_method_type,
)
try:
session = await self._get_session()
async with session.post(
f'{API_BASE_URL}/payment/',
json=payload,
headers={
'Content-Type': 'application/json',
'Authorization': self._basic_auth_header(),
'Idempotence-Key': idempotence_key,
},
) as response:
data = await response.json(content_type=None)
if response.status == 200 and data.get('success') is True:
result = data.get('result', {})
logger.info(
'PayPear API payment created',
order_id=order_id,
paypear_id=result.get('id'),
)
return result
error_msg = data.get('message') or data.get('error') or str(data)
logger.error(
'PayPear create_payment error',
status_code=response.status,
error_msg=error_msg,
response_data=data,
)
raise PayPearAPIError(response.status, error_msg)
except aiohttp.ClientError as e:
logger.exception('PayPear API connection error', error=e)
raise
async def get_payment(self, payment_id: str) -> dict[str, Any]:
"""
Получает информацию о платеже по ID.
GET /v1/payment/{id}/
"""
logger.info('PayPear get_payment', payment_id=payment_id)
try:
session = await self._get_session()
async with session.get(
f'{API_BASE_URL}/payment/{payment_id}/',
headers={
'Authorization': self._basic_auth_header(),
},
) as response:
data = await response.json(content_type=None)
if response.status == 200 and data.get('success') is True:
return data.get('result', {})
error_msg = data.get('message') or data.get('error') or str(data)
logger.error(
'PayPear get_payment error',
status_code=response.status,
error_msg=error_msg,
)
raise PayPearAPIError(response.status, error_msg)
except aiohttp.ClientError as e:
logger.exception('PayPear API connection error', error=e)
raise
async def get_payment_by_order_id(self, order_id: str) -> dict[str, Any]:
"""
Получает информацию о платеже по order_id.
GET /v1/payment/order/{order_id}/
"""
logger.info('PayPear get_payment_by_order_id', order_id=order_id)
try:
session = await self._get_session()
async with session.get(
f'{API_BASE_URL}/payment/order/{order_id}/',
headers={
'Authorization': self._basic_auth_header(),
},
) as response:
data = await response.json(content_type=None)
if response.status == 200 and data.get('success') is True:
return data.get('result', {})
error_msg = data.get('message') or data.get('error') or str(data)
logger.error(
'PayPear get_payment_by_order_id error',
status_code=response.status,
error_msg=error_msg,
)
raise PayPearAPIError(response.status, error_msg)
except aiohttp.ClientError as e:
logger.exception('PayPear API connection error', error=e)
raise
def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool:
"""Верификация подписи webhook PayPear через HMAC-SHA256.
PayPear sends signature in the webhook JSON field 'signature'.
The signature is HMAC-SHA256(secret_key, raw_body).
"""
try:
if not received_signature:
logger.warning('PayPear webhook: отсутствует signature')
return False
expected = hmac.new(
self.secret_key.encode('utf-8'),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, received_signature)
except Exception as e:
logger.error('PayPear webhook verify error', error=e)
return False
# Singleton instance
paypear_service = PayPearService()
+17
View File
@@ -117,6 +117,14 @@ async def claim_phantom(
subscription_id=sub.id,
exc_info=True,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(sub, 'id') and hasattr(sub, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=sub.id,
user_id=sub.user_id,
action='update',
)
return True, phantom
@@ -205,3 +213,12 @@ async def sync_remnawave_after_phantom_merge(db: AsyncSession, user: User) -> No
user_id=user.id,
exc_info=True,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
for sub in subs:
if hasattr(sub, 'id') and hasattr(sub, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=sub.id,
user_id=sub.user_id,
action='update',
)
+8
View File
@@ -216,6 +216,14 @@ class PromoOfferService:
subscription_id=subscription.id,
exc=exc,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
await db.commit()
for payload in log_payloads:
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
from typing import Any
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.subscription import get_active_subscriptions_by_user_id
from app.database.models import User
from app.services.subscription_service import SubscriptionService
logger = structlog.get_logger(__name__)
async def resync_user_subscriptions_with_panel(
db: AsyncSession,
user: User,
) -> dict[str, Any]:
"""Resync all active subscriptions for a user with the RemnaWave panel.
Should be called after any identity change (TG linking, account merge,
email verification) to ensure the panel has up-to-date telegram_id,
email, and squads.
Returns a stats dict with keys:
synced - number of subscriptions successfully synced
failed - number of subscriptions that failed to sync
total - total number of active subscriptions found
skipped - True when the panel is not configured
"""
service = SubscriptionService()
service._refresh_configuration()
if not service.is_configured:
logger.warning(
'remnawave_resync: panel not configured, skipping resync',
user_id=user.id,
config_error=service.configuration_error,
)
return {'synced': 0, 'failed': 0, 'total': 0, 'skipped': True}
subscriptions = await get_active_subscriptions_by_user_id(db, int(user.id))
if not subscriptions:
logger.info(
'remnawave_resync: no active subscriptions found',
user_id=user.id,
)
return {'synced': 0, 'failed': 0, 'total': 0, 'skipped': False}
synced = 0
failed = 0
for subscription in subscriptions:
# Eagerly refresh tariff to avoid lazy-loading in async context.
try:
await db.refresh(subscription, ['tariff'])
except Exception as exc:
logger.debug(
'remnawave_resync: could not refresh tariff for subscription',
subscription_id=subscription.id,
error=exc,
)
# Determine whether a panel user already exists for this subscription.
if settings.is_multi_tariff_enabled():
panel_user_exists = bool(subscription.remnawave_uuid)
else:
panel_user_exists = bool(user.remnawave_uuid)
try:
if panel_user_exists:
result = await service.update_remnawave_user(
db,
subscription,
sync_squads=True,
)
else:
result = await service.create_remnawave_user(db, subscription)
if result is not None:
synced += 1
logger.info(
'remnawave_resync: subscription synced',
subscription_id=subscription.id,
user_id=user.id,
action='update' if panel_user_exists else 'create',
)
else:
failed += 1
logger.warning(
'remnawave_resync: subscription sync returned None',
subscription_id=subscription.id,
user_id=user.id,
action='update' if panel_user_exists else 'create',
)
except Exception as exc:
failed += 1
logger.error(
'remnawave_resync: unexpected error syncing subscription',
subscription_id=subscription.id,
user_id=user.id,
error=exc,
)
total = len(subscriptions)
logger.info(
'remnawave_resync: completed',
user_id=user.id,
total=total,
synced=synced,
failed=failed,
)
return {'synced': synced, 'failed': failed, 'total': total, 'skipped': False}
+154
View File
@@ -0,0 +1,154 @@
"""Deferred retry queue for failed RemnaWave API calls.
When create_remnawave_user() fails during purchase, the subscription exists
in the bot DB but not in the panel. This queue retries the operation
periodically until it succeeds or max retries are exhausted.
"""
from __future__ import annotations
import asyncio
from collections import deque
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Literal
import structlog
from app.database.database import AsyncSessionLocal
logger = structlog.get_logger(__name__)
@dataclass
class RetryItem:
subscription_id: int
user_id: int
action: Literal['create', 'update']
attempts: int = 0
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
last_error: str | None = None
class RemnaWaveRetryQueue:
def __init__(self, max_retries: int = 5, interval_seconds: int = 120) -> None:
self._queue: deque[RetryItem] = deque()
self._max_retries = max_retries
self._interval = interval_seconds
self._task: asyncio.Task | None = None
@property
def pending_count(self) -> int:
return len(self._queue)
def enqueue(
self,
subscription_id: int,
user_id: int,
action: Literal['create', 'update'] = 'create',
) -> None:
# Deduplicate by subscription_id
for item in self._queue:
if item.subscription_id == subscription_id:
return
self._queue.append(
RetryItem(
subscription_id=subscription_id,
user_id=user_id,
action=action,
)
)
logger.info(
'Enqueued RemnaWave retry',
subscription_id=subscription_id,
user_id=user_id,
action=action,
queue_size=len(self._queue),
)
async def process_pending(self) -> None:
if not self._queue:
return
from app.database.crud.subscription import get_subscription_by_id
from app.services.subscription_service import SubscriptionService
batch = list(self._queue)
self._queue.clear()
for item in batch:
item.attempts += 1
try:
async with AsyncSessionLocal() as db:
sub = await get_subscription_by_id(db, item.subscription_id)
if not sub:
logger.warning(
'Retry: subscription not found, dropping',
subscription_id=item.subscription_id,
)
continue
service = SubscriptionService()
if not service.is_configured:
self._requeue(item, 'RemnaWave not configured')
continue
if item.action == 'create':
await service.create_remnawave_user(db, sub)
else:
await service.update_remnawave_user(db, sub)
logger.info(
'Retry succeeded',
subscription_id=item.subscription_id,
attempts=item.attempts,
)
except Exception as error:
self._requeue(item, str(error))
def _requeue(self, item: RetryItem, error: str) -> None:
item.last_error = error
if item.attempts < self._max_retries:
self._queue.append(item)
logger.warning(
'Retry failed, re-enqueued',
subscription_id=item.subscription_id,
attempts=item.attempts,
max_retries=self._max_retries,
error=error,
)
else:
logger.error(
'Retry exhausted, dropping (MANUAL INTERVENTION NEEDED)',
subscription_id=item.subscription_id,
user_id=item.user_id,
attempts=item.attempts,
error=error,
)
async def start(self) -> None:
if self._task and not self._task.done():
return
self._task = asyncio.create_task(self._run_loop())
async def stop(self) -> None:
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
async def _run_loop(self) -> None:
try:
while True:
await asyncio.sleep(self._interval)
await self.process_pending()
except asyncio.CancelledError:
raise
# Global instance
remnawave_retry_queue = RemnaWaveRetryQueue()
+48
View File
@@ -1516,6 +1516,9 @@ class RemnaWaveService:
for telegram_id, db_user in bot_users_by_telegram_id.items()
if telegram_id not in panel_telegram_ids
and any(True for _ in (getattr(db_user, 'subscriptions', None) or []))
# BUG-6 fix: Skip users who have a remnawave_uuid — they exist in panel
# but may not have telegram_id set there (OAuth users who linked TG later)
and not getattr(db_user, 'remnawave_uuid', None)
]
if users_to_deactivate:
@@ -1878,6 +1881,20 @@ class RemnaWaveService:
_short_id = await generate_unique_short_id(db)
# Attempt to match tariff by allowed_squads
_matched_tariff_id = None
if _squad_uuids:
try:
from app.database.crud.tariff import get_all_active_tariffs
_all_tariffs = await get_all_active_tariffs(db)
for _t in _all_tariffs:
if _t.allowed_squads and set(_squad_uuids).issubset(set(_t.allowed_squads)):
_matched_tariff_id = _t.id
break
except Exception:
pass
new_sub = Subscription(
user_id=_bot_user.id,
status=_sub_status.value,
@@ -1892,6 +1909,7 @@ class RemnaWaveService:
remnawave_short_uuid=panel_user.get('shortUuid'),
subscription_url=panel_user.get('subscriptionUrl', ''),
subscription_crypto_link=panel_user.get('subscriptionCryptoLink', ''),
tariff_id=_matched_tariff_id,
)
db.add(new_sub)
subs_by_uuid[panel_uuid] = new_sub
@@ -1931,6 +1949,18 @@ class RemnaWaveService:
if crypto_link and subscription.subscription_crypto_link != crypto_link:
subscription.subscription_crypto_link = crypto_link
# Update squads from panel
_panel_squads = panel_user.get('activeInternalSquads', []) or []
_squad_uuids = []
if isinstance(_panel_squads, list):
for _sq in _panel_squads:
if isinstance(_sq, dict) and 'uuid' in _sq:
_squad_uuids.append(_sq['uuid'])
elif isinstance(_sq, str):
_squad_uuids.append(_sq)
if _squad_uuids and set(_squad_uuids) != set(subscription.connected_squads or []):
subscription.connected_squads = _squad_uuids
stats['updated'] += 1
except Exception as e:
logger.error(
@@ -2146,6 +2176,24 @@ class RemnaWaveService:
# traffic_limit_gb, device_limit: bot is source of truth, do not overwrite from panel
# Update connected_squads from panel (panel is source of truth for squad assignments)
active_squads = panel_user.get('activeInternalSquads', [])
panel_squad_uuids = []
if isinstance(active_squads, list):
for squad in active_squads:
if isinstance(squad, dict) and 'uuid' in squad:
panel_squad_uuids.append(squad['uuid'])
elif isinstance(squad, str):
panel_squad_uuids.append(squad)
if panel_squad_uuids and set(panel_squad_uuids) != set(subscription.connected_squads or []):
subscription.connected_squads = panel_squad_uuids
logger.info(
'Обновлены connected_squads из панели',
user_telegram_id=getattr(user, 'telegram_id', '?'),
new_squads=panel_squad_uuids,
)
new_short_uuid = panel_user.get('shortUuid')
if new_short_uuid and subscription.remnawave_short_uuid != new_short_uuid:
old_short_uuid = subscription.remnawave_short_uuid
@@ -1304,6 +1304,13 @@ class RemnaWaveWebhookService:
async def _handle_bandwidth_threshold(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
# Respect user notification preferences
from app.utils.notification_prefs import is_traffic_warning_enabled
if not is_traffic_warning_enabled(user):
logger.debug('Traffic warning disabled by user prefs', user_id=user.id)
return
# Extract threshold percentage from meta or data
percent = data.get('thresholdPercent') or data.get('threshold', '')
if not percent:
+199
View File
@@ -0,0 +1,199 @@
"""Сервис для работы с API RollyPay (rollypay.io)."""
import hashlib
import hmac
import uuid
from typing import Any
import aiohttp
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
API_BASE_URL = 'https://rollypay.io/api/v1'
class RollyPayAPIError(Exception):
"""Ошибка API RollyPay."""
def __init__(self, status_code: int, message: str):
self.status_code = status_code
self.message = message
super().__init__(f'RollyPay API error ({status_code}): {message}')
class RollyPayService:
"""Сервис для работы с API RollyPay."""
def __init__(self):
self._session: aiohttp.ClientSession | None = None
@property
def api_key(self) -> str:
return settings.ROLLYPAY_API_KEY or ''
@property
def signing_secret(self) -> str:
return settings.ROLLYPAY_SIGNING_SECRET or ''
async def _get_session(self) -> aiohttp.ClientSession:
"""Возвращает переиспользуемую HTTP-сессию."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
)
return self._session
async def close(self) -> None:
"""Закрывает HTTP-сессию."""
if self._session and not self._session.closed:
await self._session.close()
self._session = None
def _build_headers(self) -> dict[str, str]:
"""Строит заголовки запроса с X-API-Key и X-Nonce."""
return {
'Content-Type': 'application/json',
'X-API-Key': self.api_key,
'X-Nonce': str(uuid.uuid4()),
}
async def create_payment(
self,
*,
amount_value: str,
currency: str = 'RUB',
order_id: str,
payment_method: str | None = None,
description: str = '',
redirect_url: str | None = None,
success_redirect_url: str | None = None,
fail_redirect_url: str | None = None,
customer_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Создает платеж через API RollyPay.
POST /api/v1/payments
"""
payload: dict[str, Any] = {
'amount': amount_value,
'payment_currency': currency,
'order_id': order_id,
}
if payment_method:
payload['payment_method'] = payment_method
if description:
payload['description'] = description
if redirect_url:
payload['redirect_url'] = redirect_url
if success_redirect_url:
payload['success_redirect_url'] = success_redirect_url
if fail_redirect_url:
payload['fail_redirect_url'] = fail_redirect_url
if customer_id:
payload['customer_id'] = customer_id
if metadata:
payload['metadata'] = metadata
logger.info(
'RollyPay API create_payment',
order_id=order_id,
amount=amount_value,
currency=currency,
payment_method=payment_method,
)
try:
session = await self._get_session()
async with session.post(
f'{API_BASE_URL}/payments',
json=payload,
headers=self._build_headers(),
) as response:
data = await response.json(content_type=None)
if response.status == 200:
logger.info(
'RollyPay API payment created',
order_id=order_id,
payment_id=data.get('payment_id'),
pay_url=data.get('pay_url'),
)
return data
error_msg = data.get('message') or data.get('error') or str(data)
logger.error(
'RollyPay create_payment error',
status_code=response.status,
error_msg=error_msg,
response_data=data,
)
raise RollyPayAPIError(response.status, error_msg)
except aiohttp.ClientError as e:
logger.exception('RollyPay API connection error', error=e)
raise
async def get_payment(self, payment_id: str) -> dict[str, Any]:
"""
Получает информацию о платеже по ID.
GET /api/v1/payments/{paymentID}
"""
logger.info('RollyPay get_payment', payment_id=payment_id)
try:
session = await self._get_session()
async with session.get(
f'{API_BASE_URL}/payments/{payment_id}',
headers=self._build_headers(),
) as response:
data = await response.json(content_type=None)
if response.status == 200:
return data
error_msg = data.get('message') or data.get('error') or str(data)
logger.error(
'RollyPay get_payment error',
status_code=response.status,
error_msg=error_msg,
)
raise RollyPayAPIError(response.status, error_msg)
except aiohttp.ClientError as e:
logger.exception('RollyPay API connection error', error=e)
raise
def verify_webhook_signature(self, raw_body: bytes, received_signature: str, timestamp: str) -> bool:
"""Верификация подписи webhook RollyPay через HMAC-SHA256.
Signature = HMAC-SHA256(signing_secret, f"{timestamp}.{raw_body_str}")
Headers: X-Signature, X-Timestamp
"""
try:
if not received_signature or not timestamp:
logger.warning('RollyPay webhook: отсутствует signature или timestamp')
return False
raw_body_str = raw_body.decode('utf-8')
message = f'{timestamp}.{raw_body_str}'
expected = hmac.new(
self.signing_secret.encode('utf-8'),
message.encode('utf-8'),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, received_signature)
except Exception as e:
logger.error('RollyPay webhook verify error', error=e)
return False
# Singleton instance
rollypay_service = RollyPayService()
@@ -575,7 +575,7 @@ async def _auto_extend_subscription(
updated_subscription,
reset_traffic=should_reset_traffic,
reset_reason='смена тарифа' if is_tariff_change else 'продление подписки',
sync_squads=is_tariff_change,
sync_squads=True,
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
@@ -583,6 +583,14 @@ async def _auto_extend_subscription(
format_user_id=_format_user_id(user),
error=error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(updated_subscription, 'id') and hasattr(updated_subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=updated_subscription.id,
user_id=updated_subscription.user_id,
action='update',
)
await _delete_cart_for_subscription(user.id, cart_data)
await clear_subscription_checkout_draft(user.id)
@@ -953,6 +961,14 @@ async def _auto_purchase_tariff(
format_user_id=_format_user_id(user),
error=error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
# Очищаем корзину (per-subscription if subscription_id is in cart)
await _delete_cart_for_subscription(user.id, cart_data)
@@ -1303,6 +1319,14 @@ async def _auto_purchase_daily_tariff(
format_user_id=_format_user_id(user),
error=error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
# Очищаем корзину (per-subscription if subscription_id is in cart)
await _delete_cart_for_subscription(user.id, cart_data)
@@ -1634,6 +1658,14 @@ async def _auto_add_devices(
format_user_id=_format_user_id(user),
error=error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
# Очищаем корзину (транзакция уже создана в subtract_user_balance)
await _delete_cart_for_subscription(user.id, cart_data)
@@ -1982,6 +2014,14 @@ async def _auto_add_traffic(
format_user_id=_format_user_id(user),
error=error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
# Clear cart (transaction already created in subtract_user_balance)
await _delete_cart_for_subscription(user.id, cart_data)
@@ -2333,6 +2373,14 @@ async def try_auto_extend_expired_after_topup(
format_user_id=_format_user_id(user),
error=error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(updated_subscription, 'id') and hasattr(updated_subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=updated_subscription.id,
user_id=updated_subscription.user_id,
action='update',
)
texts = get_texts(getattr(user, 'language', 'ru'))
period_label = format_period_description(period_days, getattr(user, 'language', 'ru'))
@@ -2713,6 +2761,14 @@ async def try_resume_disabled_daily_after_topup(
format_user_id=_format_user_id(user),
error=error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
# Admin notification
try:
+23 -13
View File
@@ -1166,12 +1166,22 @@ class MiniAppSubscriptionPurchaseService:
logger.warning('Failed to disable trial on RemnaWave', error=trial_err, trial_id=trial_sub.id)
try:
_purch_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else getattr(user, 'remnawave_uuid', None)
)
if _purch_uuid:
# In multi-tariff mode, each subscription has its own panel user.
# A new subscription has no remnawave_uuid yet, so always CREATE.
# In single-tariff mode, reuse the user-level UUID if available.
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='miniapp purchase',
)
else:
await subscription_service.update_remnawave_user(
db,
subscription,
@@ -1179,15 +1189,15 @@ class MiniAppSubscriptionPurchaseService:
reset_reason='miniapp purchase',
sync_squads=True,
)
else:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='miniapp purchase',
)
except Exception as remnawave_error: # pragma: no cover - defensive logging
logger.error('Failed to sync subscription with RemnaWave', remnawave_error=remnawave_error)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create' if not getattr(subscription, 'remnawave_uuid', None) else 'update',
)
transaction = await create_transaction(
db=db,
+15 -8
View File
@@ -491,20 +491,20 @@ class SubscriptionRenewalService:
subscription_service = SubscriptionService()
try:
await db.refresh(user)
_renew_uuid = (
subscription_after.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription_after.remnawave_uuid
else getattr(user, 'remnawave_uuid', None)
)
if _renew_uuid:
await subscription_service.update_remnawave_user(
if settings.is_multi_tariff_enabled():
_should_create = not subscription_after.remnawave_uuid
else:
_should_create = not getattr(user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(
db,
subscription_after,
reset_traffic=reset_traffic,
reset_reason='subscription renewal',
)
else:
await subscription_service.create_remnawave_user(
await subscription_service.update_remnawave_user(
db,
subscription_after,
reset_traffic=reset_traffic,
@@ -518,6 +518,13 @@ class SubscriptionRenewalService:
subscription_after_id=subscription_after.id,
error=error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription_after.id,
user_id=subscription_after.user_id,
action='create' if not getattr(subscription_after, 'remnawave_uuid', None) else 'update',
)
transaction: Transaction | None = None
try:
+1 -1
View File
@@ -403,7 +403,7 @@ class SubscriptionService:
*,
reset_traffic: bool = False,
reset_reason: str | None = None,
sync_squads: bool = False,
sync_squads: bool = True,
) -> RemnaWaveUser | None:
try:
user = await get_user_by_id(db, subscription.user_id)
+8
View File
@@ -768,6 +768,14 @@ class UserService:
subscription_id=sub.id,
error=e,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(sub, 'id') and hasattr(sub, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=sub.id,
user_id=sub.user_id,
action='update',
)
await db.commit()
logger.info('Админ разблокировал пользователя', admin_id=admin_id, user_id=user_id)
+85
View File
@@ -0,0 +1,85 @@
"""User notification preferences helper.
Reads notification_settings JSON from User model and provides
typed access to individual preferences with sensible defaults.
"""
from __future__ import annotations
from typing import Any
from app.database.models import User
# Defaults match the frontend and cabinet/routes/notifications.py
_DEFAULTS: dict[str, Any] = {
'subscription_expiry_enabled': True,
'subscription_expiry_days': 3,
'traffic_warning_enabled': True,
'traffic_warning_percent': 80,
'balance_low_enabled': False,
'balance_low_threshold': 100, # kopeks
'news_enabled': True,
'promo_offers_enabled': True,
}
def get_user_notification_pref(user: User, key: str) -> Any:
"""Get a single notification preference for user.
Falls back to default if not set.
"""
settings_data = getattr(user, 'notification_settings', None) or {}
return settings_data.get(key, _DEFAULTS.get(key))
def is_subscription_expiry_enabled(user: User) -> bool:
"""Check if subscription expiry notifications are enabled for user."""
return bool(get_user_notification_pref(user, 'subscription_expiry_enabled'))
def get_subscription_expiry_days(user: User) -> int:
"""Get the number of days before expiry to notify."""
value = get_user_notification_pref(user, 'subscription_expiry_days')
try:
return max(1, int(value))
except (TypeError, ValueError):
return 3
def is_traffic_warning_enabled(user: User) -> bool:
"""Check if traffic warning notifications are enabled for user."""
return bool(get_user_notification_pref(user, 'traffic_warning_enabled'))
def get_traffic_warning_percent(user: User) -> int:
"""Get the traffic usage percentage threshold for warning."""
value = get_user_notification_pref(user, 'traffic_warning_percent')
try:
return max(50, min(99, int(value)))
except (TypeError, ValueError):
return 80
def is_balance_low_enabled(user: User) -> bool:
"""Check if low balance notifications are enabled for user."""
return bool(get_user_notification_pref(user, 'balance_low_enabled'))
def get_balance_low_threshold(user: User) -> int:
"""Get the low balance threshold in kopeks."""
value = get_user_notification_pref(user, 'balance_low_threshold')
try:
return max(0, int(value))
except (TypeError, ValueError):
return 100
def is_news_enabled(user: User) -> bool:
"""Check if news notifications are enabled for user."""
return bool(get_user_notification_pref(user, 'news_enabled'))
def is_promo_offers_enabled(user: User) -> bool:
"""Check if promo offer notifications are enabled for user."""
return bool(get_user_notification_pref(user, 'promo_offers_enabled'))
+24
View File
@@ -7055,6 +7055,14 @@ async def switch_tariff_endpoint(
)
except Exception as e:
logger.error('Ошибка синхронизации с RemnaWave при смене тарифа', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
lang = getattr(user, 'language', settings.DEFAULT_LANGUAGE)
if upgrade_cost > 0:
@@ -7245,6 +7253,14 @@ async def purchase_traffic_topup_endpoint(
await service.enable_remnawave_user(_en_uuid)
except Exception as e:
logger.error('Ошибка синхронизации с RemnaWave при докупке трафика', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
# Создаем транзакцию
await create_transaction(
@@ -7484,6 +7500,14 @@ async def toggle_daily_subscription_pause_endpoint(
logger.warning('Failed to sync squads after user creation (miniapp)', error=squad_err)
except Exception as e:
logger.error('Ошибка синхронизации с RemnaWave при возобновлении', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
# Send admin notification about daily subscription resume
if resume_transaction is not None:
+3 -1
View File
@@ -18,7 +18,9 @@ def _normalize_period_discounts(value: dict[object, object] | None) -> dict[int,
except (TypeError, ValueError):
continue
return normalized or None
# Return empty dict (not None) so the backend can distinguish
# "clear all discounts" ({}) from "don't touch discounts" (None/absent).
return normalized
class PromoGroupResponse(BaseModel):
+101
View File
@@ -1241,6 +1241,105 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
routes_registered = True
# PayPear webhook
if settings.is_paypear_enabled():
@router.get(settings.PAYPEAR_WEBHOOK_PATH)
async def paypear_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'paypear_webhook',
'enabled': settings.is_paypear_enabled(),
}
)
@router.post(settings.PAYPEAR_WEBHOOK_PATH)
async def paypear_webhook(request: Request) -> JSONResponse:
try:
raw_body = await request.body()
payload = json.loads(raw_body)
except Exception as parse_error:
logger.error('PayPear webhook: failed to parse JSON', parse_error=parse_error)
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
# Извлекаем подпись из тела webhook
received_signature = payload.get('signature', '')
from app.services.paypear_service import paypear_service
if not paypear_service.verify_webhook_signature(raw_body, received_signature):
logger.warning('PayPear webhook: invalid signature')
return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN)
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_paypear_webhook',
)
if not success:
logger.error(
'PayPear webhook processing failed',
data=payload.get('object'),
)
except Exception as e:
logger.exception('PayPear webhook processing error', error=e)
# Always return 200 — PayPear may retry on non-200
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
routes_registered = True
# RollyPay webhook
if settings.is_rollypay_enabled():
@router.get(settings.ROLLYPAY_WEBHOOK_PATH)
async def rollypay_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'rollypay_webhook',
'enabled': settings.is_rollypay_enabled(),
}
)
@router.post(settings.ROLLYPAY_WEBHOOK_PATH)
async def rollypay_webhook(request: Request) -> JSONResponse:
try:
raw_body = await request.body()
payload = json.loads(raw_body)
except Exception as parse_error:
logger.error('RollyPay webhook: failed to parse JSON', parse_error=parse_error)
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
# Подпись через заголовки X-Signature и X-Timestamp
received_signature = request.headers.get('X-Signature', '')
timestamp = request.headers.get('X-Timestamp', '')
from app.services.rollypay_service import rollypay_service
if not rollypay_service.verify_webhook_signature(raw_body, received_signature, timestamp):
logger.warning('RollyPay webhook: invalid signature')
return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN)
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_rollypay_webhook',
)
if not success:
logger.error(
'RollyPay webhook processing failed',
data=payload.get('payment_id'),
)
except Exception as e:
logger.exception('RollyPay webhook processing error', error=e)
# Always return 200 — RollyPay retries on non-200 with exponential backoff
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
routes_registered = True
if routes_registered:
@router.get('/health/payment-webhooks')
@@ -1261,6 +1360,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
'kassa_ai_enabled': settings.is_kassa_ai_enabled(),
'riopay_enabled': settings.is_riopay_enabled(),
'severpay_enabled': settings.is_severpay_enabled(),
'paypear_enabled': settings.is_paypear_enabled(),
'rollypay_enabled': settings.is_rollypay_enabled(),
}
)
@@ -0,0 +1,28 @@
"""add broadcast category column
Revision ID: 0054
Revises: 0053
Create Date: 2026-04-10
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0054'
down_revision: Union[str, None] = '0053'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'broadcast_history',
sa.Column('category', sa.String(20), nullable=False, server_default='system'),
)
def downgrade() -> None:
op.drop_column('broadcast_history', 'category')
@@ -0,0 +1,28 @@
"""add pending_campaign_slug to users
Revision ID: 0055
Revises: 0054
Create Date: 2026-04-13
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0055'
down_revision: Union[str, None] = '0054'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'users',
sa.Column('pending_campaign_slug', sa.String(64), nullable=True),
)
def downgrade() -> None:
op.drop_column('users', 'pending_campaign_slug')
@@ -0,0 +1,57 @@
"""create cabinet_refresh_tokens table
Revision ID: 0056
Revises: 0055
Create Date: 2026-04-13
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0056'
down_revision: Union[str, None] = '0055'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_table(table: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return table in inspector.get_table_names()
def _has_index(table: str, index_name: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return index_name in [idx['name'] for idx in inspector.get_indexes(table)]
def upgrade() -> None:
if not _has_table('cabinet_refresh_tokens'):
op.create_table(
'cabinet_refresh_tokens',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('token_hash', sa.String(255), nullable=False),
sa.Column('device_info', sa.String(500), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
)
if not _has_index('cabinet_refresh_tokens', 'ix_cabinet_refresh_tokens_id'):
op.create_index('ix_cabinet_refresh_tokens_id', 'cabinet_refresh_tokens', ['id'])
if not _has_index('cabinet_refresh_tokens', 'ix_cabinet_refresh_tokens_token_hash'):
op.create_index('ix_cabinet_refresh_tokens_token_hash', 'cabinet_refresh_tokens', ['token_hash'], unique=True)
if not _has_index('cabinet_refresh_tokens', 'ix_cabinet_refresh_tokens_user'):
op.create_index('ix_cabinet_refresh_tokens_user', 'cabinet_refresh_tokens', ['user_id'])
def downgrade() -> None:
op.drop_index('ix_cabinet_refresh_tokens_user', table_name='cabinet_refresh_tokens')
op.drop_index('ix_cabinet_refresh_tokens_token_hash', table_name='cabinet_refresh_tokens')
op.drop_index('ix_cabinet_refresh_tokens_id', table_name='cabinet_refresh_tokens')
op.drop_table('cabinet_refresh_tokens')
@@ -0,0 +1,52 @@
"""alter notification_settings from json to jsonb
Revision ID: 0057
Revises: 0056
Create Date: 2026-04-13
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0057'
down_revision: Union[str, None] = '0056'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _get_column_type(table: str, column: str) -> str | None:
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT data_type FROM information_schema.columns "
"WHERE table_name = :table AND column_name = :column"
),
{'table': table, 'column': column},
)
row = result.fetchone()
return row[0] if row else None
def upgrade() -> None:
col_type = _get_column_type('users', 'notification_settings')
if col_type and col_type != 'jsonb':
op.execute(
sa.text(
"ALTER TABLE users "
"ALTER COLUMN notification_settings TYPE jsonb "
"USING notification_settings::jsonb"
)
)
def downgrade() -> None:
op.execute(
sa.text(
"ALTER TABLE users "
"ALTER COLUMN notification_settings TYPE json "
"USING notification_settings::json"
)
)
@@ -0,0 +1,47 @@
"""create paypear_payments table
Revision ID: 0058
Revises: 0057
Create Date: 2026-04-15
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0058'
down_revision: Union[str, None] = '0057'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'paypear_payments',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True),
sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True),
sa.Column('paypear_id', sa.String(64), unique=True, nullable=True, index=True),
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('payment_url', sa.Text(), nullable=True),
sa.Column('payment_method', sa.String(32), nullable=True),
sa.Column('metadata_json', sa.JSON(), nullable=True),
sa.Column('callback_payload', sa.JSON(), nullable=True),
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column(
'transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True
),
)
def downgrade() -> None:
op.drop_table('paypear_payments')
@@ -0,0 +1,47 @@
"""create rollypay_payments table
Revision ID: 0059
Revises: 0058
Create Date: 2026-04-15
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0059'
down_revision: Union[str, None] = '0058'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'rollypay_payments',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True),
sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True),
sa.Column('rollypay_payment_id', sa.String(128), unique=True, nullable=True, index=True),
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('payment_url', sa.Text(), nullable=True),
sa.Column('payment_method', sa.String(32), nullable=True),
sa.Column('metadata_json', sa.JSON(), nullable=True),
sa.Column('callback_payload', sa.JSON(), nullable=True),
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column(
'transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True
),
)
def downgrade() -> None:
op.drop_table('rollypay_payments')
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.45.2"
version = "3.48.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
Generated
+1 -2
View File
@@ -564,7 +564,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
{ url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
{ url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
{ url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" },
{ url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
{ url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
{ url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
@@ -1143,7 +1142,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.43.1"
version = "3.46.1"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },