Compare commits

...

287 Commits

Author SHA1 Message Date
c0mrade 5bf4aeb31e Merge pull request #2733 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.32.1
2026-03-13 19:17:37 +03:00
github-actions[bot] 7356921eeb chore(main): release 3.32.1 2026-03-13 16:11:39 +00:00
c0mrade f24337fb41 Merge pull request #2732 from BEDOLAGA-DEV/dev
Dev
2026-03-13 19:11:14 +03:00
c0mrade 69a38dad25 fix: invalid ISO date format in node usage stats API call
datetime.now(UTC).isoformat() produces +00:00 suffix, appending Z
created invalid +00:00Z format causing RemnaWave API 500 errors.
Use .replace('+00:00', 'Z') instead of concatenation.
2026-03-13 18:58:36 +03:00
c0mrade aa3459b846 fix: platega webhook ID fallback for SBP and card payments
SBP sends `id`, cards send `transactionId`. Use fallback chain
to resolve transaction ID from all known field variants.
2026-03-13 18:41:14 +03:00
c0mrade 4d695be7d5 fix: resolve MissingGreenlet in switch_tariff endpoint
Use local subscription variable and db.refresh() to avoid lazy-load
of expired relationship after subtract_user_balance invalidates
the User identity map entry.
2026-03-13 18:30:32 +03:00
Egor b8fcbc7661 Merge pull request #2729 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.32.0
2026-03-13 06:19:11 +03:00
github-actions[bot] 96042782d9 chore(main): release 3.32.0 2026-03-13 03:18:41 +00:00
Egor a625eaae4f Merge pull request #2728 from BEDOLAGA-DEV/dev
Dev
2026-03-13 06:18:08 +03:00
Egor 869fe06831 Merge pull request #2727 from BEDOLAGA-DEV/main
w
2026-03-13 06:10:27 +03:00
Fringg a5fbd7400f fix: user deletion FK error + connected_squads None TypeError
Bug 1: DELETE /cabinet/admin/users/{id}/full failed with
"saved_payment_methods_user_id_fkey" FK violation.
Root cause: delete_user_account() didn't clean up saved_payment_methods
and riopay_payments before deleting the user row.
Fix: add DELETE for both tables before final user deletion.

Bug 2: show_user_management crashed with TypeError on
len(subscription.connected_squads) when connected_squads was None.
Root cause: remnawave_webhook_service explicitly set connected_squads=None
when clearing subscription data, but 5 call sites assumed it was always a list.
Fix: change None assignment to [] + add "or []" guards at all 5 call sites.
2026-03-13 06:08:54 +03:00
Egor 995d66483b Delete docs/plans directory 2026-03-13 05:57:37 +03:00
Egor 5c77bd7a0f Merge pull request #2726 from BEDOLAGA-DEV/feat/unified-pricing-engine
Feat/unified pricing engine
2026-03-13 05:55:27 +03:00
Fringg 04697fd4cb style: ruff format 8 files 2026-03-13 05:53:02 +03:00
Fringg c9f2dffabf fix: address 6-agent review findings for PricingEngine
H1: log error when tariff_id set but tariff relationship not loaded
H2: warn on CLASSIC_PERIOD_PRICES→PERIOD_PRICES fallback
M1: fix float division in miniapp tariff purchase (use PricingEngine.apply_discount)
M2: fix format_period Russian pluralization for teen-hundreds (111-119, etc.)
M3: deduplicate _resolve_discount_percent — import from pricing_utils
M4: fix N+1 queries in compute_simple_subscription_price (batch fetch)
M5: add period_days validation tests (negative, zero, float)
M6: add user=None tests for tariff and classic modes
M7: fix float division in calculate_prorated_price (use // instead of /)
L1: add context to _calculate_servers_price error log
L2: add comment clarifying ClassicBreakdown.group_discount_pct type
L3: add test for original_total property
L4: inline _apply_percentage_discount wrapper in subscription_purchase_service
L5: replace global _server_id_counter with itertools.count() in tests
2026-03-13 05:45:46 +03:00
Fringg fe4e6acb53 refactor: unify first-purchase discount algorithm with PricingEngine
apply_percentage_discount now delegates to PricingEngine.apply_discount
(floor division). Removes ruble-rounding that caused inconsistency between
first-purchase and renewal pricing.

subscription_purchase_service._apply_percentage_discount now delegates
to the shared apply_percentage_discount.

All 60+ callers across handlers, keyboards, cabinet, miniapp, balance
automatically use the unified algorithm without code changes.
2026-03-13 05:30:44 +03:00
Fringg e24b911283 refactor: migrate all callers to pricing_engine singleton + fix miniapp discount
- 13 PricingEngine() instantiation sites → import pricing_engine singleton
- miniapp _apply_promo_discount now delegates to PricingEngine.apply_discount
  (fixes float division vs floor division inconsistency)
2026-03-13 05:22:18 +03:00
Fringg b551def340 refactor: add typed breakdowns + module-level singleton to PricingEngine
- TariffBreakdown and ClassicBreakdown frozen dataclasses for type safety
- Module-level `pricing_engine` singleton eliminates repeated instantiation
- breakdown remains dict[str, Any] at runtime for backward compatibility
2026-03-13 05:22:10 +03:00
Fringg 5e9a462261 refactor: extract shared formatting helpers into app/utils/formatting.py
Consolidate duplicated _format_traffic, _format_price_kopeks, _format_period
from tariff_purchase.py and admin/tariffs.py into a shared module.
2026-03-13 05:22:04 +03:00
Fringg 3a3bd9d499 test: expand PricingEngine tests + update CryptoBot payment tests
- Add 45 unit tests covering tariff/classic modes, discounts, edge cases
- Update CryptoBot payment tests for new PricingEngine integration
- Add original_total identity tests for both pricing modes
2026-03-13 05:12:47 +03:00
Fringg 75dbd2b4fc refactor: migrate remaining callers to PricingEngine + cleanup dead CRUD
- Migrate bot purchase handlers, menu, admin users to PricingEngine
- SubscriptionRenewalService.finalize() accepts both old and new pricing types
- Remove dead subscription CRUD pricing functions (get_subscription_renewal_cost etc.)
- Remove dead pricing_utils functions
2026-03-13 05:12:32 +03:00
Fringg b4ef52caa4 fix: payment providers — lock_user_for_update + commit=False atomicity
All payment providers now use lock_user_for_update before balance mutations
and commit=False pattern for atomic payment status + fulfillment.
Tribute service refund also uses proper locking.
2026-03-13 05:12:15 +03:00
Fringg ae99358ae9 fix: pricing audit — display/charge parity, race conditions, balance locks
M-2: tariff_purchase.py — _apply_promo_discount delegates to PricingEngine,
     _get_user_period_discount returns (group_pct, offer_pct, combined),
     all ~15 call sites updated for display/charge price parity

M-4: miniapp switch_tariff — add FOR UPDATE lock on subscription,
     commit=False for atomic balance+transaction, emit_transaction_side_effects

M-6: CryptoBot — defer status commit (commit=False) so webhook retry works
     if fulfillment fails

WARNING: add lock_user_for_update before balance_kopeks mutations in
     contest_attempt_service, wheel_service, admin/referrals,
     account_merge_service, cabinet/routes/contests
2026-03-13 05:11:59 +03:00
Fringg 08bea704de fix: address review findings from 5-agent audit
- Add period_days validation (> 0) in PricingEngine
- Add int() cast for tariff period_prices (prevent JSON type errors)
- Fix structlog.get_logger(__name__) in pricing_engine
- Use pricing.original_total property instead of manual reconstruction
- Add CryptoBot price decrease audit logging
- Remove stale cart price fallback in auto-purchase (fail instead)
- Fix _apply_promo_discount_for_tariff to use PricingEngine.apply_discount
- Remove dead code: _get_tariff_price_for_period, _get_countries_price,
  calculate_addon_price_with_remaining_period, _resolve_addon_discount_percent
2026-03-13 05:11:35 +03:00
Fringg 18e2e7841a fix: add period_days whitelist validation and type annotations
Security fix: cabinet /renew endpoint now validates period_days against
available periods (tariff or settings), preventing arbitrary period abuse.

Also:
- Add proper type annotations (AsyncSession, Subscription, User) to PricingEngine
- Add max(0, final_total) guard in both tariff and classic modes
- Type breakdown field as dict[str, Any]
2026-03-12 23:27:51 +03:00
Fringg 652b6dabde refactor: migrate menu.py renewal pricing to PricingEngine
Replace 3 renewal_service.calculate_pricing() calls with
PricingEngine.calculate_renewal_price() in the balance activation handler.
finalize() already supports RenewalPricing via duck typing.
2026-03-12 23:15:37 +03:00
Fringg c9a9816daa refactor: remove dead pricing code and fix miniapp classic mode
- Remove SubscriptionService.calculate_renewal_price (zero callers, replaced by PricingEngine)
- Remove SubscriptionService.calculate_renewal_price_with_months (zero callers)
- Remove _calculate_subscription_renewal_pricing wrapper in miniapp (zero callers)
- Fix miniapp classic mode: pass PricingEngine result directly to finalize() instead of old wrapper
- Fix potential NameError: pricing_snapshot in cryptobot path used undefined 'pricing' variable
- Net: -396 lines of duplicate pricing logic
2026-03-12 23:12:59 +03:00
Fringg 49c0f3fc10 refactor: migrate admin user price calculation to PricingEngine
Replace SubscriptionService.calculate_renewal_price() with PricingEngine
in _calculate_subscription_period_price for admin panel.
2026-03-12 23:04:29 +03:00
Fringg cb43acab31 refactor: migrate miniapp renewal display + execute to PricingEngine 2026-03-12 23:00:58 +03:00
Fringg f59b215645 style: fix import sorting and formatting after lint
ruff auto-fix for import ordering in cabinet/subscription.py and
formatting adjustments across changed files.
2026-03-12 22:58:35 +03:00
Fringg 3efa24bab3 refactor: make finalize() accept both old and new pricing types
SubscriptionRenewalService.finalize() now supports both
SubscriptionRenewalPricing and RenewalPricing from PricingEngine.
Adapts access to promo_discount_value, server_ids, and
servers_individual_prices via duck typing.
2026-03-12 22:58:08 +03:00
Fringg bd2e93a6a5 refactor: migrate cart auto-purchase to PricingEngine (fresh calc)
Replaces stale cart-based pricing and _apply_promo_discount_for_tariff
(4th discount formula with float division) with fresh PricingEngine
calculation. Falls back to saved cart price on PricingEngine error.
2026-03-12 22:51:03 +03:00
Fringg 978f68e7be refactor: migrate recurrent and monitoring services to PricingEngine
Mechanical re-point of calculate_renewal_price calls to use unified
PricingEngine. Both services now get consistent pricing with correct
discount formulas and server fallback behavior.
2026-03-12 22:50:13 +03:00
Fringg 28fc36dca4 refactor: migrate cabinet renewal display + execute to PricingEngine
Replaces inline pricing logic in get_renewal_options and renew_subscription
with unified PricingEngine.calculate_renewal_price(). Fixes:
- Wrong discount formula (int(p*(100-d)/100) vs integer floor division)
- Missing servers/traffic costs in classic mode display
- Inconsistent discount stacking between display and execute paths
2026-03-12 22:49:22 +03:00
Fringg 1660b24f98 fix: add per-category discounts and months multiplier to classic mode
Classic mode now correctly:
- Applies separate promo group discounts per category (period, servers,
  traffic, devices) via promo_group.get_discount_percent(category, days)
- Multiplies servers/traffic/devices monthly prices by months_in_period
- Applies promo offer discount to entire subtotal after per-category discounts
- Tracks total group discount as sum of per-category discounts
2026-03-12 22:46:34 +03:00
Fringg acf27a1023 refactor: migrate bot renewal execute to PricingEngine
Replace ~95 lines of manual pricing calculation in confirm_extend_subscription
with PricingEngine.calculate_renewal_price. Removes per-component discount
logic (period, servers, devices, traffic with separate category discounts,
months multiplication, and validate_pricing_calculation check). Downstream
logic preserved: balance check, cart save, subtract_user_balance,
subscription update, Remnawave sync, transaction creation, and admin
notification all use pricing.final_total and pricing.promo_offer_discount.

Removes unused imports: _apply_promo_offer_discount, validate_pricing_calculation.
2026-03-12 22:41:41 +03:00
Fringg ce82c2c009 refactor: migrate bot renewal display to PricingEngine
Replace manual per-component price calculation in handle_extend_subscription
with PricingEngine.calculate_renewal_price. This eliminates ~55 lines of
duplicated pricing logic (period, servers, devices, traffic calculations with
separate category-specific promo group discounts and months multiplication)
in favor of a single PricingEngine call per period. Also fixes double-application
of promo offer discount that existed in the old code path.
2026-03-12 22:37:55 +03:00
Fringg e6ebc6722d refactor: migrate try_auto_extend_expired to PricingEngine
Replace SubscriptionService.calculate_renewal_price() call in
try_auto_extend_expired_after_topup with PricingEngine.calculate_renewal_price().
Add structured log with pricing breakdown after calculation.
All downstream business logic (balance check, deduction, extend) unchanged.
2026-03-12 22:32:41 +03:00
Fringg 02e5401327 feat: implement calculate_renewal_price with tariff and classic modes
Add the main public method calculate_renewal_price to PricingEngine,
routing to _calculate_tariff_mode or _calculate_classic_mode based on
whether the subscription has a linked tariff. Both modes apply stacked
discounts (promo-group then promo-offer). Classic mode tries
CLASSIC_PERIOD_PRICES first, falling back to PERIOD_PRICES. Adds 8
new tests covering both modes, discounts, extra devices, and fallback.
2026-03-12 22:29:44 +03:00
Fringg c3bb63ffed feat: add CLASSIC_PERIOD_PRICES to config
Add a standalone dict that always reflects env PRICE_*_DAYS settings,
independent of tariffs mode. Unlike PERIOD_PRICES (which may use DB
tariff prices), CLASSIC_PERIOD_PRICES is the canonical source for
classic (non-tariff) subscription pricing. Includes refresh helper.
2026-03-12 22:29:37 +03:00
Fringg 88369eec50 feat: add _calculate_servers_price (fixed fallback) and _calculate_traffic_price
_calculate_servers_price ALWAYS uses real server.price_kopeks even when
is_available=False or is_full=True, fixing the silent zero-price bug.
_calculate_traffic_price separates base from purchased GB to prevent
purchased top-ups from inflating the tier lookup.
2026-03-12 22:20:48 +03:00
Fringg 83ca51cd5b feat: add RenewalPricing dataclass and PricingEngine discount methods 2026-03-12 22:18:08 +03:00
Egor f9dad615ee Merge pull request #2721 from FireWookie/feature/recurrent_method_inline
Отображение привязанных карт в разделе в боте
2026-03-12 20:28:31 +03:00
Fringg ba049ca017 fix: resolve merge conflict with dev (accept calc_device_limit_on_tariff_switch) 2026-03-12 20:27:48 +03:00
Fringg 585baaf63c fix: harden remnawave API error handling and YooKassa user cross-validation
- remnawave_api: use str() before .lower() to handle non-string API messages
- yookassa recovery: cross-validate user_telegram_id metadata against resolved
  user to prevent misattribution when legacy telegram_id fits in int32 range
2026-03-12 20:17:51 +03:00
Fringg 04197817fe fix: downgrade known-harmless RemnaWave 400s to warning level
"User already enabled" and "User already disabled" are expected
responses when reactivating subscriptions (e.g., traffic top-up on
active subscription with exhausted traffic). These should not
trigger error notifications in the admin chat.
2026-03-12 20:08:53 +03:00
Fringg b2ee6c766a fix: add missing settings import in admin_users tariff switch 2026-03-12 19:59:59 +03:00
Fringg d35ee58aa6 fix: harden YooKassa webhook recovery user lookup
- Reject user_id <= 0 early (corrupted metadata)
- Use `is None` checks instead of `or` to avoid falsy-value collisions
- Separate int parse from DB call in telegram_id fallback
- Move _INT32_MAX to module-level constant
2026-03-12 19:51:52 +03:00
Fringg 815a1d9136 fix: handle legacy telegram_id in YooKassa webhook recovery metadata
Legacy payments may store telegram_id (>int32) in metadata['user_id']
instead of internal User.id. The recovery path now:
- Detects values exceeding int32 range and queries by telegram_id
- Falls back to metadata['user_telegram_id'] if primary lookup fails
- Resolves to internal user.id before creating FK-linked payment record
2026-03-12 19:41:48 +03:00
Fringg b7775b72dc fix: guard rollback on commit flag, add flush to promo_offer_log
- subtract_user_balance: only rollback when commit=True, re-raise when
  commit=False so caller controls transaction lifecycle
- log_promo_offer_action: add db.flush() when commit=False to surface
  constraint errors immediately instead of deferring to caller's commit
2026-03-12 19:33:25 +03:00
Fringg ba54819f9c fix: atomicity refactor, review fixes, and DELETED recovery logging
- subtract_user_balance: add commit=False parameter for atomic balance+subscription ops
- extend_subscription: add commit=False parameter, propagate to clear_notifications
- wata_service: wire _MIN_EXPIRATION_MINUTES constant to actual usage
- admin_users: fix no-op ternary in sync_user_from_panel timezone normalization
- start.py: log warning when DELETED recovery zeros non-zero balance (3 locations)
- remnawave_service: preserve PromoCodeUse records and used_promocodes in force_cleanup
2026-03-12 19:26:36 +03:00
Fringg 266340aad1 fix: prevent balance loss on auto-purchase for DISABLED subscriptions and fix WATA expiration
- Block auto-purchase from stale cart when subscription is DISABLED
  (balance deduction is irreversible, Remnawave update would fail)
- Preserve user balance in force_cleanup_user_data (paid money must not be destroyed)
- Keep has_had_paid_subscription flag on cleanup (prevents promo code abuse)
- Add warning in sync_from_panel when local end_date is newer than panel
- Fix WATA payment expiration: enforce minimum 15 minutes to avoid
  hitting WATA API's exclusive lower bound (now + 10 min)
2026-03-12 19:09:16 +03:00
Fringg 8f434525eb feat: add LIMITED subscription status and preserve extra devices on tariff switch
- Add SubscriptionStatus.LIMITED for traffic-exhausted subscriptions
- Webhook user.limited now sets LIMITED directly instead of DISABLED
- Add LIMITED to reactivation, extend, resume, auto-purchase, contest eligibility
- Add traffic_exhausted error response in miniapp API
- Fix device_limit being overwritten on tariff switch in all code paths:
  admin change_tariff, user switch-tariff, miniapp, bot tariff_purchase,
  auto_purchase_service — now preserves extra purchased devices via
  calc_device_limit_on_tariff_switch() helper
- Fix truthiness checks on device_limit (0 is valid, use `is not None`)
2026-03-12 18:35:59 +03:00
Egor efa1b11db5 Merge pull request #2724 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.31.0
2026-03-12 08:30:35 +03:00
github-actions[bot] d0ce193edb chore(main): release 3.31.0 2026-03-12 05:30:07 +00:00
Egor 92d872236f Merge pull request #2723 from BEDOLAGA-DEV/dev
Dev
2026-03-12 08:29:34 +03:00
Egor a11f492801 Merge pull request #2722 from BEDOLAGA-DEV/main
ц
2026-03-12 08:25:26 +03:00
Fringg c8162505ed chore: apply ruff formatting to 4 files 2026-03-12 08:24:47 +03:00
Fringg 076290e0c1 feat: auto-sync squads to Remnawave when admin updates tariff
When admin changes allowed_squads or external_squad_uuid on a tariff,
automatically sync the new squad config to all active/trial subscriptions
in Remnawave panel via a background task (fire-and-forget).
2026-03-12 08:20:25 +03:00
firewookie 2f5674fcd7 правки линтера 2026-03-12 09:40:29 +05:00
FireWookie b9058e115a Merge pull request #8 from FireWookie/dev
Dev
2026-03-12 09:39:58 +05:00
firewookie 673afccb8c правки линтера 2026-03-12 09:35:54 +05:00
firewookie 1badb39c49 правки по импортам 2026-03-12 09:35:07 +05:00
firewookie 23ff40cd2c Отображение привязанных карт в разделе в боте 2026-03-12 09:31:46 +05:00
Fringg bf72f241d8 fix: preserve purchased devices when admin changes user tariff
Previously subscription.device_limit was blindly overwritten with the new
tariff's base limit, losing any extra devices the user had purchased.
Now extra devices are calculated from the old tariff base and carried over,
capped at tariff.max_device_limit or global MAX_DEVICES_LIMIT.
2026-03-12 06:55:56 +03:00
Fringg 12ae871653 feat: referral links now point to web cabinet instead of bot
Centralized referral link generation into settings.get_referral_link().
When CABINET_URL is configured, links use {CABINET_URL}?ref={code}.
Falls back to Telegram bot deep link when CABINET_URL is not set.

- URL-encodes referral_code for safety
- Handles CABINET_URL with existing query params (uses & vs ?)
- Guards against None referral_code in all call sites
- QR code caching uses link hash for auto-invalidation
2026-03-12 06:45:17 +03:00
Fringg 8a362db783 fix: correct skipped_count in sync-squads circuit breaker and simplify ternary
- Add missing skipped_count increment when early-aborting due to circuit breaker
- Remove redundant `new_squads if new_squads else []` (already [] when empty)
- Add comment explaining asyncio safety of shared counter mutations
2026-03-12 06:28:13 +03:00
Fringg b1e2146254 feat: add sync-squads endpoint for bulk updating subscription squads in Remnawave
POST /admin/tariffs/{tariff_id}/sync-squads updates active_internal_squads
and external_squad_uuid for all active/trial subscriptions on a tariff.

- Concurrent API calls (semaphore=5) with circuit breaker (10 consecutive failures)
- Local DB updated only on successful API response to avoid split-brain
- Error messages sanitized (details in server logs only)
- Uses joinedload to avoid N+1 query for User.remnawave_uuid
2026-03-12 06:17:13 +03:00
Fringg 68bc8eb57c fix: update promo group via M2M table so admin changes persist
The admin endpoint wrote only to the legacy users.promo_group_id FK,
which got overwritten by sync_user_primary_promo_group on the next
transaction. Now writes to user_promo_groups M2M table (authoritative
source) and re-derives the FK via sync.

Also re-raise exceptions in _sync_user_primary_promo_group to prevent
committing inconsistent state between M2M and FK columns.
2026-03-12 06:03:26 +03:00
Fringg 9957259881 fix: add post_update=True to User.referrals self-referential relationship
Without post_update, SQLAlchemy's flush ordering cannot resolve the
circular dependency when both a user and their referrer are in the same
session. This caused referred_by_id to be silently NULLed during flush,
breaking Telegram login (which eagerly loads User.referrer) and causing
apparent admin rights loss in the cabinet.

OAuth login was unaffected because it uses a bare select(User) without
eager loading the referrer relationship.

Root cause confirmed by 6 parallel investigation agents tracing the
exact code paths through get_user_by_telegram_id → selectinload →
flush → circular dependency.
2026-03-12 05:39:29 +03:00
Fringg b3f3eba575 fix: prevent account takeover via auto_login_token, ensure promo group on all purchase paths
- Gate auto_login_token generation behind is_new_account flag in all 3 locations
  (fulfill_purchase PENDING/DELIVERED paths + activate_purchase) — prevents attacker
  from buying cheapest plan with victim's email to get their session token
- Assign default promo group in all _find_or_create_user paths including telegram
  IntegrityError fallbacks (7 return paths total)
- Create transaction records for landing purchases so promo group auto-assignment
  and contest tracking work correctly
- Add _resolve_payment_method helper for enum conversion with sub-option suffix stripping
2026-03-12 05:26:16 +03:00
Fringg a798f1143e refactor: remove estimated price from balance, simplify server sync, fix HTML injection
- Remove estimated renewal price display from balance top-up screen
- Remove country name generation during server/squad sync, use original RemnaWave name as display_name
- Add html.escape() for all display_name/country name values rendered in HTML-parsed Telegram messages
2026-03-12 04:58:49 +03:00
Fringg cb5126aff8 feat: add show_in_gift toggle for tariffs in admin panel
Add a per-tariff visibility flag (show_in_gift) that controls whether
a tariff appears in the /gift section. Enforced server-side in gift
config query, gift purchase endpoint, and landing page gift purchases.

Includes Alembic migration with idempotency guard and server_default.
2026-03-12 04:15:40 +03:00
Fringg 8b35428055 fix: reactivate subscription after traffic top-up when status is EXPIRED
When traffic is exhausted, RemnaWave may send user.expired webhook setting
local status to EXPIRED (not just DISABLED). reactivate_subscription() only
handled DISABLED→ACTIVE, silently ignoring EXPIRED subscriptions. After
purchasing additional GB, the subscription stayed expired and VPN remained
blocked despite payment.

Changes:
- reactivate_subscription() now handles both DISABLED and EXPIRED→ACTIVE
  when end_date is still in the future
- Inverted null end_date guard to block reactivation (defense-in-depth)
- Added enable_remnawave_user() call after update in all traffic/device
  top-up paths to ensure panel exits LIMITED state
- Gated enable call on subscription.status == 'active' to prevent
  enabling when reactivation was a no-op
- Fixed all 12 call sites across bot handlers, cabinet routes,
  miniapp, webapi, and auto-purchase service
2026-03-12 03:57:35 +03:00
Fringg 5424d8c314 fix: add Telegram Stars payment support for gift subscriptions
- Add telegram_stars handler in create_guest_payment() using
  bot.create_invoice_link() with guest_purchase_{token} payload
- Add guest_purchase_ prefix handling in Stars pre-checkout and
  successful_payment handlers with amount tolerance check (±5%)
- Pass Bot instance to PaymentService when payment method is Stars
- Add purchase_token format validation via regex guard
2026-03-12 03:32:08 +03:00
Egor df7411138e Merge pull request #2718 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.30.0
2026-03-11 03:56:12 +03:00
github-actions[bot] 4545bef7ea chore(main): release 3.30.0 2026-03-11 00:55:56 +00:00
Egor d7eb1e776a Merge pull request #2716 from BEDOLAGA-DEV/dev
Dev
2026-03-11 03:55:35 +03:00
Egor f8fc382143 Merge pull request #2717 from BEDOLAGA-DEV/main
w
2026-03-11 03:55:10 +03:00
Fringg e67b8e448e fix: reset subscription for paid users, trial-to-paid tariff conversion, gift purchase MissingGreenlet
- Remove is_active_paid_subscription guard from reset-subscription endpoint
- Add is_trial=False and status=ACTIVE on admin tariff change (guarded by
  tariff.is_trial_available and end_date check)
- Eagerly load user_promo_groups and promo_group in gift purchase locked query
- Move user_promo_groups access inside try/except in get_primary_promo_group()
2026-03-11 03:46:39 +03:00
Fringg bca8bab433 feat: add gifts section to admin user detail API
Add GET /{user_id}/gifts endpoint returning sent and received gift
subscriptions with COUNT queries for true totals, token truncation
for security, and noload() optimization for unused relationships.
2026-03-11 03:30:12 +03:00
Fringg 2fd0f6aa4e feat: add promo group and promo offer discounts to gift subscriptions
Apply promo group and active promo offer discounts to gift purchase flow.
Discounts stack multiplicatively with max(1, price) floor. FOR UPDATE
row lock prevents concurrent promo offer double-spend. Promo offers
consumed after purchase in both balance and gateway modes.
2026-03-11 02:58:34 +03:00
Fringg 864a4ed700 fix: record transactions for free tariff switches and admin tariff changes
- Add transaction records for free tariff switches (downgrade, upgrade_cost=0) in miniapp and cabinet
- Add atomic transaction records for admin tariff changes in bot handler and cabinet API
- Use commit=False for admin flows to ensure subscription change and transaction are committed together
2026-03-11 02:17:35 +03:00
Fringg 2879996455 fix: use keyword args for Path.mkdir in asyncio.to_thread
Positional args caused mode=True(1) and exist_ok=False(default),
raising FileExistsError when directories already existed.

Fixed 8 instances: 2 in log_rotation_service, 6 in backup_service.
2026-03-11 01:22:02 +03:00
Egor 4b4fced442 Merge pull request #2714 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.29.0
2026-03-10 23:01:35 +03:00
github-actions[bot] 8859e73890 chore(main): release 3.29.0 2026-03-10 20:01:13 +00:00
Egor d79866819c Merge pull request #2713 from BEDOLAGA-DEV/dev
Dev
2026-03-10 23:00:25 +03:00
Fringg 5a62f91ca2 chore: ruff format 4 files 2026-03-10 22:57:32 +03:00
Fringg def594bbb5 fix: panel sync now updates end_date in both directions
Previously, both webhook (user.modified) and batch sync paths
only updated end_date if the panel date was LATER than the local date.
This silently blocked any date reduction from the panel, causing
the bot to show stale expiry dates after admin changes in the panel.

Now the panel is treated as authoritative — end_date is synced
in both directions (forward and backward) for ACTIVE subscriptions.
2026-03-10 22:23:39 +03:00
Fringg 641ff86bf6 fix: stars rate rounding + device/traffic purchase stats
- Stars: ceil → round в rubles_to_stars, нормализация kopeks в cabinet invoice
- Устройства/трафик: добавлен transaction_type в subtract_user_balance,
  покупки устройств и трафика теперь создают SUBSCRIPTION_PAYMENT (не WITHDRAWAL),
  что исправляет отображение в статистике продаж
2026-03-10 21:50:20 +03:00
Fringg 2e59330e95 chore: resolve uv.lock merge conflict 2026-03-10 21:19:28 +03:00
Fringg 3c96c2affd fix: 3 bugs — notification type, referral with channel sub, BOT_USERNAME
1. Admin notification showed "renewal" instead of "first purchase" for new
   users because has_had_paid_subscription was set before notification.
   All 21 call sites now pass explicit purchase_type.

2. Partner referral not counted when mandatory channel subscription enabled.
   required_sub_channel_check saved campaign_id but not referrer_id from
   campaign.partner_user_id. Also removed duplicate DB query.

3. BOT_USERNAME auto-detection moved before web server start to close
   race window on /cabinet/branding/telegram-widget endpoint.
2026-03-10 21:19:18 +03:00
c0mrade 541f64d5bc Merge pull request #2711 from FireWookie/dev_nikita
FIX PR Problems
2026-03-10 14:17:12 +03:00
firewookie e82a1ccf6d FIX PR Problems 2026-03-10 16:16:18 +05:00
c0mrade 015be30a27 Merge pull request #2710 from FireWookie/feature/riopay
FIX PR Problems
2026-03-10 14:13:25 +03:00
firewookie 6817b9e256 FIX PR Problems 2026-03-10 16:11:28 +05:00
c0mrade 59248011c2 Merge pull request #2678 from FireWookie/dev_nikita
Add: Рекуренты Юкасса + fix: скрытие нулевых бонусов в реферальной программе
2026-03-10 13:06:35 +03:00
firewookie 39d007ff3e FIX PR Problems 2026-03-10 15:01:28 +05:00
firewookie 94b211e2a7 FIX PR Problems 2026-03-10 15:00:41 +05:00
firewookie c84dbf82fc update 2026-03-10 14:50:26 +05:00
FireWookie 9ad684c8c9 Merge pull request #7 from FireWookie/dev
Dev
2026-03-10 14:42:53 +05:00
FireWookie d147be0316 Merge pull request #6 from FireWookie/dev
Dev
2026-03-10 14:42:05 +05:00
c0mrade 9281523e96 Merge pull request #2690 from FireWookie/feature/riopay
RioPay
2026-03-10 12:41:53 +03:00
firewookie fd3466b75c fix PR problems 2026-03-10 14:29:29 +05:00
FireWookie dcfd54a7cb Merge pull request #5 from FireWookie/dev
Dev
2026-03-10 14:18:49 +05:00
FireWookie 5c2e5dfaab Merge branch 'BEDOLAGA-DEV:main' into feature/riopay 2026-03-10 14:14:23 +05:00
firewookie df112f3659 fix PR problems 2026-03-10 14:08:10 +05:00
Fringg a90d2d9367 fix: 3 critical issues from second-round review
1. Balance-mode gift purchase leaked full 64-char token in response.
   Gateway path truncated to [:12] but balance path didn't. Fixed.

2. retry_stuck_pending_activation referenced GuestPurchase.updated_at
   which doesn't exist on the model. Changed to paid_at (mirrors
   retry_stuck_paid_purchases pattern).

3. clear_notifications() called db.commit() unconditionally, defeating
   commit=False in replace_subscription. Added commit parameter with
   default=True for backward compat, passed through from caller.
2026-03-10 07:09:39 +03:00
Fringg 5c34656476 fix: address review findings from 6-agent audit
1. Truncate tokens to 12 chars in all API responses (SentGift,
   PendingGift, ReceivedGift, PurchaseStatus, PurchaseResponse,
   return URL) — full token no longer leaves the server
2. Status endpoint supports prefix-based token lookup
3. create_paid_subscription/replace_subscription accept commit=False
   — activate_purchase now uses single atomic commit for subscription
   + purchase status update (fixes double-commit gap)
4. Bot handler uses flush() instead of commit() before svc_activate
   — consistent with cabinet endpoint, allows rollback on failure
5. Add retry_stuck_pending_activation() for purchases stuck in
   PENDING_ACTIVATION status (10 min threshold)
6. Add varchar_pattern_ops index for prefix queries on token column
2026-03-10 07:02:14 +03:00
Fringg 8a8337f538 fix: add minimum 8-char length check for gift token in bot deep link 2026-03-10 06:56:08 +03:00
Fringg 42b6c80a48 refactor: rename GIFTCODE_ start parameter prefix to GIFT_ 2026-03-10 06:48:49 +03:00
Fringg b30c73c300 feat: prevent self-activation of gift codes
Buyer cannot activate their own gift, both via cabinet API
(returns 400 "Cannot activate your own gift") and bot deep link
(silently skips activation).
2026-03-10 06:44:58 +03:00
Fringg 363ccce56d fix: refresh user subscription after gift activation in /start
After svc_activate creates a subscription, the user object still
has stale cached data. Refresh the subscription attribute so the
main menu immediately shows the active subscription status.
2026-03-10 06:36:33 +03:00
Fringg 0005d59da1 fix: remove begin_nested that breaks activate_purchase transaction
activate_purchase -> create_paid_subscription calls db.commit()
internally, which closes the savepoint context and causes
InvalidRequestError on subsequent db.refresh(). Replace savepoint
with a plain commit before calling svc_activate.
2026-03-10 06:33:31 +03:00
Fringg 38c6adfdb4 fix: pass full token to svc_activate instead of truncated prefix
Telegram truncates start parameters to 64 chars, so gift_token from
deep link may be a prefix. svc_activate does exact match internally,
so we must pass gift_purchase.token (full token from DB) instead.
2026-03-10 06:23:55 +03:00
Fringg 4fb72ae6e3 fix: support prefix-based gift code lookup for activation
Displayed gift codes (GIFT-XXXXXXXXXXXX) are 12-char prefixes of the
full 64-char token. Activation now accepts prefix match (min 8 chars)
so both the short display code and full token work. Also fixes Telegram
deep link truncation (64-char limit cuts the token).
2026-03-10 06:20:07 +03:00
Fringg 05bcac502e fix: code-only gifts skip fulfillment in gateway webhook + retry service
- Gateway webhook: skip fulfill_purchase() for code-only gifts (is_gift=True, no recipient)
- Retry service: exclude code-only gifts from stuck PAID retry query
- Status endpoint: return is_code_only and purchase_token for code-only gifts
2026-03-10 06:03:26 +03:00
Fringg 769d3a0b30 refactor: deduplicate gift activation in start.py
Replace inline gift activation block (30+ lines) with a call to
_activate_pending_gift_after_registration() helper. Eliminates
code duplication between existing-user and new-user activation paths.
2026-03-10 05:41:05 +03:00
Fringg 5ffce175dc feat: gift subscription code-only purchase + activation via deep link
- Add code-only gift purchase (no recipient required)
- Gift activate endpoint: accept PAID + PENDING_ACTIVATION statuses
- Bot deep link: /start GIFTCODE_{token} auto-activation for new and existing users
- Add _activate_pending_gift_after_registration() helper with savepoint isolation
- Security: FOR UPDATE on activation queries to prevent race conditions
- Security: rate limiting on activate, ownership check before status leak
- Security: uniform 404 responses to prevent token enumeration
- Add selectinload for tariff/user/buyer relationships in all gift queries
- Add .limit(100) to pending gifts query
- Make recipient_type/recipient_value optional in GiftPurchaseRequest schema
2026-03-10 05:37:41 +03:00
Egor 1a2f0fcbe8 Merge pull request #2709 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.28.1
2026-03-10 03:39:48 +03:00
github-actions[bot] fd1e728396 chore(main): release 3.28.1 2026-03-10 00:39:15 +00:00
Egor ec41d65501 Merge pull request #2708 from BEDOLAGA-DEV/dev
Dev
2026-03-10 03:38:54 +03:00
Egor 5212877801 Merge pull request #2707 from BEDOLAGA-DEV/main
w
2026-03-10 03:37:02 +03:00
Fringg bc9003c336 chore: ruff format 2026-03-10 03:36:32 +03:00
Fringg fcdeff1ee5 fix: migrate pricing to days-based proration, fix promo revenue leaks, fix admin panel bugs
- Migrate all addon pricing (devices, traffic, countries) from months-based to days-based proration
- Remove get_remaining_months() utility, use days_left / 30 consistently
- Fix promo/campaign balance bonuses counted as revenue in reports (add REAL_PAYMENT_METHODS filter)
- Fix partner stats, campaign stats, miniapp stats, referral fraud detection promo deposit leaks
- Fix admin balance history showing deductions with + sign (use -abs for expense types)
- Fix promo offer deactivation returning 400 for non-promocode offers
- Fix daily tariff renewal requesting 30-day renewal instead of 1-day purchase
2026-03-10 03:31:07 +03:00
Egor bcc35d6e22 Merge pull request #2706 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.28.0
2026-03-09 23:41:57 +03:00
github-actions[bot] b850e81897 chore(main): release 3.28.0 2026-03-09 20:41:35 +00:00
Egor 4d9e42c3f1 Merge pull request #2705 from BEDOLAGA-DEV/dev
Dev
2026-03-09 23:41:08 +03:00
Egor 834a0478ae Merge pull request #2704 from BEDOLAGA-DEV/main
w
2026-03-09 23:39:56 +03:00
Fringg 0e968987fb style: format guest_purchase_service.py with ruff 2026-03-09 23:39:24 +03:00
Fringg acd2cff9ca style: format inline.py with ruff 2026-03-09 23:38:34 +03:00
Fringg 69dbd6a2df fix: enforce HTTPS for webapp mode, deduplicate keyboard builder, fix long line 2026-03-09 23:37:35 +03:00
Fringg 497a8ee5b5 feat: add open_in setting for custom buttons (external browser / webapp) 2026-03-09 23:33:18 +03:00
Fringg dd8d7f6920 feat: add cabinet menu layout editor with row arrangement, custom URL buttons, and drag-and-drop reordering
- Add menu_layout_cache.py for CABINET_MENU_LAYOUT in-process cache
- Add admin_menu_layout.py routes (GET/PUT/POST reset) with merged view
- Rewrite _build_cabinet_main_menu_keyboard to use cached row layout
- Support custom URL buttons with style, emoji, labels, enabled toggle
- Atomic dual-key DB writes for layout + button styles
- Add language button to default layout and DEFAULT_BUTTON_STYLES
- Pydantic validation with Literal types, max_length, duplicate ID checks
- Register routes and cache loading in bot startup
2026-03-09 23:07:32 +03:00
Fringg b9089e693f fix: normalize threshold 0→NULL in create_promo_group for consistency 2026-03-09 22:16:30 +03:00
Fringg b815abf2b1 fix: loyalty tiers current status based on spending, not assigned group
- current_tier_name and is_current now determined by highest achieved
  tier threshold instead of user's assigned promo group
- Backend update_promo_group converts threshold 0 to NULL for clean state
2026-03-09 22:08:41 +03:00
Fringg 95a32e8574 fix: payment gateway issues — YooKassa polling, PAL24 card 500
- YooKassa: return local_payment_id instead of UUID for frontend polling
  (parseInt on UUID produced wrong ID → eternal spinner)
- PAL24: remove unsupported payment_method param from API call
  (cabinet and miniapp routes — URL selection is client-side)
2026-03-09 21:53:53 +03:00
Fringg cd04f3b622 feat: implement gateway payment for gifts, persist recipient warning
- Replace 501 stub with full gateway payment flow via PaymentService
- Move telegram username pre-check (DB-first) above gateway/balance branch
- Add recipient_warning column to GuestPurchase model + migration 0034
- Return warning in gift purchase status endpoint
- Add db.refresh(purchase) after commit in gateway branch
2026-03-09 21:25:49 +03:00
Fringg 6a4140e3e2 fix: harden gift subscription feature after multi-agent review
- Add self-gift prevention (telegram username + email)
- Unify 404 response on purchase status (eliminate token oracle)
- Add period_days upper bound (le=3650) in schema
- Handle NULL paid_at in retry query with or_()
- Capture purchase_token before fulfill_purchase (session safety)
- Upgrade Bot API pre-check logging to warning level
- Add exc_info=True for monitoring retry errors
- Add database indexes: (user_id, is_gift, status), (status, paid_at), buyer_user_id
- Use datetime instead of str for created_at in PendingGiftResponse
- Align GuestPurchase model __table_args__ with all migrations
2026-03-09 20:34:39 +03:00
Fringg f80b058380 fix: negate GIFT_PAYMENT amounts and remove dead code 2026-03-09 18:47:36 +03:00
Fringg 6a61b09575 feat: add cabinet gift subscription API routes and schemas
Create Pydantic schemas for gift config/purchase/status responses,
FastAPI routes for GET /gift/config, POST /gift/purchase, and
GET /gift/purchase/{token}, update GuestPurchaseService.create_purchase
to accept optional source and buyer_user_id params with nullable landing,
and register the gift router in the cabinet routes.
2026-03-09 18:44:38 +03:00
Fringg 759bfe1bdb feat: add CABINET_GIFT_ENABLED branding toggle 2026-03-09 18:41:07 +03:00
Fringg 0936d4a7f6 feat: add source and buyer_user_id fields to GuestPurchase model
- Add source column (landing/cabinet) to track purchase origin
- Add buyer_user_id FK to link cabinet gift purchases to authenticated users
- Add GIFT_PAYMENT to TransactionType enum for balance deductions
- Add foreign_keys disambiguation to existing user relationship
- Migration 0032: adds columns, index on source, FK constraint
2026-03-09 18:35:52 +03:00
firewookie c7bebae14a back docker 2026-03-09 14:11:50 +05:00
firewookie 8ee287f8cd remove locales from git 2026-03-09 14:11:34 +05:00
firewookie 6f99b83c61 remove locales from git 2026-03-09 14:09:16 +05:00
firewookie 1a3c6fafa3 update saved payment method 2026-03-09 14:07:42 +05:00
firewookie be2ec091a6 Правки по замечаниям 2026-03-09 14:06:00 +05:00
firewookie d4dc0b76ba fix linter 2026-03-09 13:38:46 +05:00
firewookie 2dfd0e6452 Правки по замечаниям 2026-03-09 13:34:25 +05:00
Fringg 680c22c017 fix: support Telegram OIDC id_token in account linking endpoint
Email users couldn't link Telegram when OIDC was enabled because
the link_telegram endpoint only accepted init_data and Login Widget
data. Add id_token field to LinkTelegramRequest with JWKS validation,
replay protection, and rate limiting.
2026-03-09 06:23:02 +03:00
Egor 8c9efd5127 Merge pull request #2703 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.27.0
2026-03-09 05:08:42 +03:00
github-actions[bot] 4663097a24 chore(main): release 3.27.0 2026-03-09 02:07:55 +00:00
Egor dc51a55c98 Merge pull request #2702 from BEDOLAGA-DEV/dev
Dev
2026-03-09 05:07:31 +03:00
Fringg 275f249bbd fix: encode payment status in provider return URLs and wire failed_url
- Add &status=success/failed to cabinet return URLs for instant UX feedback
  without needing API auth in external browser
- Platega: pass cabinet_failed_url (was hardcoded to server URL)
- Heleket: add success_url param, pass cabinet_success_url for url_success
- WATA: add failed_url param, pass cabinet_failed_url to failRedirectUrl
- CloudPayments: add failed_url param, pass cabinet_failed_url
- Strip trailing slash from CABINET_URL for safety
2026-03-09 04:59:56 +03:00
Fringg 7a9264b173 fix: latest-payment endpoint returns all payments, not just pending
The /latest endpoint was using list_recent_pending_payments which only
returns unpaid payments. By the time the user returns from the payment
provider, the webhook has already marked the payment as paid, so the
endpoint returned 404. Now queries the payment table directly without
filtering by is_paid status.
2026-03-09 04:41:25 +03:00
Fringg 32d58b04b9 fix: add method query param to return_url and latest-payment endpoint
Payment providers redirect to external browser where sessionStorage is
unavailable. Now includes method in return_url query params and adds
GET /pending-payments/{method}/latest endpoint so TopUpResult can poll
payment status without sessionStorage data.
2026-03-09 04:34:14 +03:00
Fringg 7ca96195a7 fix: pass cabinet return_url to payment providers for top-up redirects
Payment providers were redirecting users back to the bot after completing
cabinet top-up payments. Now passes CABINET_URL/balance/top-up/result as
return_url to YooKassa, Platega, Heleket, WATA, and CloudPayments.
2026-03-09 04:16:10 +03:00
Fringg 5752b5e7c6 chore: apply ruff formatting to 4 files 2026-03-09 03:02:32 +03:00
Egor e6f577697b Merge pull request #2701 from BEDOLAGA-DEV/main
w
2026-03-09 03:01:02 +03:00
Fringg f4a776319e fix: add table existence guards to migrations for optional payment tables
Migrations 0019, 0022, 0031 crashed with UndefinedTableError when
payment provider tables (e.g. kassa_ai_payments) or contest_templates
did not exist. Added _table_exists() checks before ALTER/DROP operations.
2026-03-09 02:57:01 +03:00
Fringg 2649e12f64 fix: use parsed HTML length for Telegram caption limit checks
Replace hardcoded raw HTML length checks (len(text) <= 900/1000/1024)
with centralized caption_exceeds_telegram_limit() that strips HTML tags
and unescapes entities before measuring against the real 1024-char limit.
Fixes logo disappearing when promo discounts add HTML markup to captions.
2026-03-09 02:43:43 +03:00
Fringg 4a5cacda38 fix: resolve concurrent AsyncSession bug and sanitize error responses
- Fix critical concurrency issue in propagate_tariff_squads: preload
  users/tariffs before asyncio.gather, use single API client, no DB
  operations inside gather, single commit after all API calls
- Replace all str(e) leaks in admin_users.py with sanitized messages
- Fix double callback.answer by using callback.message.answer for
  failure alerts
- Move PropagateSquadsResult to module level, use field(default_factory)
- Compute traffic_strategy once before gather instead of N times
- Add warning logging on tariff refresh failures
- Reset synced counters on commit failure for accurate reporting
2026-03-09 02:26:38 +03:00
Fringg 79161eaae4 refactor: move squad propagation to service layer with parallel Remnawave sync
- Move _propagate_squads_to_subscriptions from handler to
  SubscriptionService.propagate_tariff_squads()
- Use asyncio.gather with semaphore (concurrency=5) for parallel
  Remnawave API calls instead of sequential O(N)
- Track failed subscription IDs for better observability
- Fix get_all_server_squads limit=50 default in admin handlers
  (now limit=10000 to prevent silent truncation)
- Add docstring to force_panel_delete parameter
- Return PropagateSquadsResult dataclass with total/synced/failed_ids
2026-03-09 01:58:23 +03:00
Fringg 289cbe966e fix: conditional log messages and sanitize panel_error in user deletion
- Log disable success/failure separately instead of unconditional success
- Sanitize panel_error to not leak internal exception details to API
- Make fallback disable log conditional on actual result
2026-03-09 01:52:37 +03:00
Fringg 7ccfb66690 fix: propagate tariff squad changes to existing subscriptions and fix user deletion from Remnawave
Squad toggle: when admin changes servers for a tariff, the changes now
propagate to all active/trial subscriptions and sync to Remnawave panel.
Previously only took effect on new purchases.

User deletion: full delete from Cabinet now actually deletes from Remnawave
panel. Previously lied about panel deletion status and skipped deletion
for users with active subscriptions.
2026-03-09 01:46:45 +03:00
Fringg 536525c9c0 fix: admin tariff server selection - 64-byte overflow and callback routing conflicts
1. Shortened squad toggle callback_data from admin_tariff_toggle_squad
   to trf_sq to stay within Telegram's 64-byte callback_data limit
   (was overflowing at tariff_id >= 10)

2. Fixed toggle_tariff handler capturing squad/promo/daily/traffic_topup
   toggle callbacks by adding exclusion filters

3. Fixed admin_tariff_edit_traffic capturing admin_tariff_edit_traffic_topup
   by registering traffic_topup handler before traffic handler

4. Fixed admin_tariff_delete capturing admin_tariff_delete_confirm
   by registering delete_confirm handler before delete handler
2026-03-09 01:23:03 +03:00
Fringg 4186159a61 fix: keep DB session alive in Tribute payment notification handler
The _send_success_notification method was closing the DB session (via
break) before calling send_cart_notification_after_topup, causing all
post-topup auto-renewal logic to silently fail for Tribute payments.

Moved break after all work is done so the session stays open during
auto-renewal operations. Added None guard for user lookup.
2026-03-09 01:07:47 +03:00
Fringg 6349b2f442 fix: align tariff pricing with calculate_renewal_price reference
- balance/main.py: single period discount on combined total (base + devices),
  add promo-offer discount, fix device_limit fallback to tariff_device_limit
- pricing.py: same combined discount + promo-offer, proper device_limit
  fallback matching reference (is not None check)
- admin/users.py: delegate to calculate_renewal_price() which handles both
  tariff and classic modes correctly, removing classic-only calculate_subscription_price
- menu.py: use renewal_service.calculate_pricing() for both price check and
  charge to ensure consistency, add try/except with user-facing error,
  show actual charged amount in success message
2026-03-09 00:39:16 +03:00
Fringg bfbefeb1e2 fix: renewal cost estimate double-counts servers and traffic in tariff mode
In tariff mode, period_prices already includes servers and traffic costs.
But show_payment_methods() and get_subscription_cost() were using the classic
additive formula, adding server and traffic prices on top of the tariff price.

Example: 49₽ tariff + 150₽ server + 150₽ traffic = 349₽ shown, should be 49₽.

Now both functions detect tariff mode and only add extra device costs beyond
the tariff's device_limit. Classic mode formula unchanged.
2026-03-08 23:14:57 +03:00
Fringg f9f07f360c fix: enforce tariff device_price and max_device_limit across all purchase paths
The miniapp, legacy cabinet endpoint, auto-purchase service, and Telegram bot
handlers were using only global settings (PRICE_PER_DEVICE, MAX_DEVICES_LIMIT)
for device purchases, completely ignoring tariff-level device_price_kopeks and
max_device_limit. This allowed users to buy devices when tariff price was 0
(should be blocked) and exceed the tariff's max device limit.

Fixed in all 4 code paths:
- miniapp _build_subscription_settings + update_subscription_devices_endpoint
- cabinet legacy POST /devices (+ added subscription status check, RemnaWave sync)
- subscription_auto_purchase_service._auto_add_devices
- telegram bot handlers confirm_change_devices, execute_change_devices, confirm_add_devices
2026-03-08 23:08:32 +03:00
Fringg 770b31d3d0 feat: auto-resume disabled daily subscriptions on balance topup
- Add try_resume_disabled_daily_after_topup() for instant resume when balance is topped up
- Fix all 5 resume paths to charge daily fee BEFORE activating subscription
- Remove unsafe inline auto-resume from add_user_balance() that bypassed fee charging
- Add NULL-safe is_daily_paused filter in subscription queries
- Use create_remnawave_user() instead of enable_remnawave_user() for full VPN panel sync
- Add DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP localization key (ru, en, fa, zh, ua)
2026-03-08 21:36:17 +03:00
Egor ae710f41fc Merge pull request #2700 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.26.0
2026-03-08 15:47:30 +03:00
github-actions[bot] f86b8614b1 chore(main): release 3.26.0 2026-03-08 12:46:24 +00:00
Egor 61b2fcc2aa Merge pull request #2699 from BEDOLAGA-DEV/dev
Dev
2026-03-08 15:46:01 +03:00
Fringg 928e3e98f8 chore: format balance/main.py and promocode.py 2026-03-08 15:41:59 +03:00
Fringg 7dc5e4ab94 fix: auto-purchase classic extend missing device_limit and traffic_limit_gb
- Add device_limit and traffic_limit_gb to classic extend cart data in
  confirm_extend_subscription handler
- Add classic mode branch in cabinet renew_subscription to save
  device_limit and traffic_limit_gb (previously only saved for tariffs)
- Ensure device_limit >= DEFAULT_DEVICE_LIMIT when converting trial
  subscription to paid via auto-extend
- Add None guards for subscription.device_limit in both trial and
  non-trial branches of _apply_extension_updates
2026-03-08 15:31:58 +03:00
Fringg 5ebe1072c9 fix: quick topup buttons include device/server/traffic costs, broadcast button crash on media messages
- Quick amount buttons now calculate full renewal cost (base + devices + servers + traffic with discounts)
- Tariff mode uses tariff-specific device pricing (device_price_kopeks, device_limit)
- Broadcast inline buttons no longer crash with "no text in message to edit" on photo/video messages
- Media messages are now handled in _edit_with_photo: delete old message + send new text
2026-03-08 14:58:17 +03:00
Fringg 20727b1017 fix: respect send_before_menu flag for pinned messages during new user registration
All 6 registration paths now check pinned_message.send_before_menu
to send pinned message before or after the menu, matching the
existing user flow behavior.
2026-03-08 14:25:54 +03:00
Fringg f4eeb9a503 fix: multiple payment and notification bugs
- CloudPayments: add missing process_referral_topup, has_made_first_topup flag, and admin notification (matching other adapters)
- Promocode: handle TelegramBadRequest for broadcast messages without text (fallback to answer())
- Devices: unify price prorating to day-based calculation (matching cabinet behavior)
- Auth: pass Bot instance to process_referral_registration for campaign referral notifications
- Wata: remove WATA_TERMINAL_PUBLIC_ID from is_wata_enabled() (not used in API calls), make type field conditional, add transactionId webhook fallback
2026-03-08 14:17:44 +03:00
Fringg 1f664a9083 fix: remove is_active_paid_subscription guard from admin deactivation
The guard silently blocked admins from deactivating active paid
subscriptions, returning a generic error with no explanation.
Admin deactivation is intentional (with confirmation step) and
should not be prevented. The guard remains in automated processes
(monitoring, broadcast, user_service) where it makes sense.
2026-03-08 13:05:52 +03:00
Fringg 330d1cb6fe fix: gift purchase notification and activation flow
- Refresh purchase.user relationship after setting user_id to fix
  stale None value that prevented Telegram gift notifications
- Route gift purchases with expired subscriptions through
  PENDING_ACTIVATION instead of auto-activating
- Hide subscription URL from gift buyer in API response
2026-03-08 12:47:25 +03:00
firewookie 8e53b81b3d fix recurrent linter 2026-03-08 09:36:47 +05:00
firewookie 69ca37bc6e fix project 2026-03-08 09:35:10 +05:00
FireWookie 26daf9f6c8 Merge pull request #4 from FireWookie/main
Merge as main project
2026-03-08 09:32:37 +05:00
firewookie 34aae0dd26 fix formatting 2026-03-08 09:30:47 +05:00
firewookie 0551a6e23c fix migrations 2026-03-08 09:28:32 +05:00
firewookie 92cc602892 Merge remote-tracking branch 'origin/feature/riopay' into dev 2026-03-08 09:26:32 +05:00
firewookie 555b887952 Merge remote-tracking branch 'origin/dev' into dev 2026-03-08 09:23:08 +05:00
firewookie 848c9f71a2 reviewers fix 2026-03-08 09:22:44 +05:00
FireWookie 4477e03d83 Merge pull request #3 from FireWookie/main
merge as main project
2026-03-08 09:20:33 +05:00
Fringg 9ba61a0879 feat: add telegram gift notification with inline activation button
- New gift_activation handler for gift_activate:{id} callback buttons
- Send Telegram notification to gift recipients with activate button
- Add skip_notification param to activate_purchase to prevent duplicates
- Fix telegram username regex minimum length (4→5 chars) in landing routes
- Add BOT_TOKEN guard in telegram gift notification sender
- Pre-resolve notification params before commit to avoid DetachedInstanceError
2026-03-07 20:39:04 +03:00
Egor d7f05ae409 Merge pull request #2696 from BEDOLAGA-DEV/main
w
2026-03-07 17:42:20 +03:00
Egor bf2d5e48e5 Merge pull request #2694 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.25.0
2026-03-07 17:22:38 +03:00
github-actions[bot] 93bf21e55b chore(main): release 3.25.0 2026-03-07 14:21:25 +00:00
Egor fea44f5ad4 Merge pull request #2693 from BEDOLAGA-DEV/dev
Dev
2026-03-07 17:20:52 +03:00
Fringg 923b36a8b9 fix: use --frozen instead of --locked in Dockerfile to avoid version mismatch 2026-03-07 17:17:35 +03:00
Fringg a7fea86c99 chore: update uv.lock 2026-03-07 17:15:49 +03:00
Fringg fbe56c15ac style: ruff format 2026-03-07 17:13:56 +03:00
Egor 213f82b9a4 Merge pull request #2692 from BEDOLAGA-DEV/main
w
2026-03-07 17:13:03 +03:00
Fringg d72ea6b7f9 fix: remaining context_vars/SAMPLE_CONTEXTS mismatches found by agents
- traffic_reset: traffic_limit → reset_gb, current_limit_gb
- payment_received: amount → formatted_amount
- guest_activation_required: added missing is_gift to context_vars
- daily_debit SAMPLE_CONTEXTS: added missing amount_rubles, new_balance_rubles
- traffic_reset SAMPLE_CONTEXTS: aligned with runtime keys
- payment_received SAMPLE_CONTEXTS: added amount_rubles
2026-03-07 17:11:57 +03:00
Fringg c507634398 fix: align subscription_renewed/activated context_vars with runtime keys
- subscription_renewed: new_end_date → new_expires_at
- subscription_activated: end_date → expires_at
- Added traffic_limit_gb and device_limit to both types
- Fixed SAMPLE_CONTEXTS keys to match
2026-03-07 17:11:57 +03:00
Fringg c9ea2b15e9 fix: strip newlines from subject substitution, fix subscription notification context
- Defense-in-depth: strip \r\n from context values in subject line
  substitution to prevent email header injection at composition layer
- Add missing tariff_name to classic mode subscription notification context
  (prevents literal {tariff_name} in DB override templates)
- Remove SQLAlchemy model object from notification context dict
  (str() on model produces garbage in DB override templates)
2026-03-07 17:11:57 +03:00
Fringg ab5313a381 fix: align context_vars and SAMPLE_CONTEXTS with actual runtime context keys
TEMPLATE_TYPES context_vars showed wrong placeholder names to admins
(e.g. 'amount' instead of 'formatted_amount', 'balance' instead of
'formatted_balance'). SAMPLE_CONTEXTS had mismatched keys causing
test emails to render with empty values.

Fixed types: balance_topup, balance_change, autopay_success,
autopay_insufficient_funds, daily_debit, daily_insufficient_funds,
referral_bonus.
2026-03-07 17:11:57 +03:00
Fringg 351d714f2d fix: substitute sample context in admin test email for template overrides
Same bug as notification_delivery_service — send_test_email used
get_template_override (raw template) instead of get_rendered_override.
Admins testing custom templates saw literal {days_left} placeholders
instead of sample values.
2026-03-07 16:48:18 +03:00
Fringg d52c87b2b7 fix: substitute context variables in email template overrides
The send_notification method was calling get_template_override which
returns raw template HTML without variable substitution. Placeholders
like {days_left} and {expires_at} were sent to users as literal text.

Switched to get_rendered_override which properly substitutes context
variables via str.replace before wrapping in the base email template.
2026-03-07 16:40:35 +03:00
Fringg d9f9f3dca1 fix: add or [] guard to remaining connected_squads call site in fulfill_purchase
Missed the create_paid_subscription branch (line 275) in the previous
commit — now all 4 call sites consistently use `or []` to guard against
None from tariff.allowed_squads JSON column.
2026-03-07 16:34:15 +03:00
Fringg 44d46feb0a fix: correct device_limit and connected_squads in guest purchase fulfillment
- Remove `or settings.DEFAULT_DEVICE_LIMIT` from fulfill_purchase expired
  subscription branch — tariff.device_limit is NOT NULL so the `or` pattern
  would incorrectly convert 0 (unlimited) to DEFAULT_DEVICE_LIMIT
- Add `or []` guard to connected_squads in activate_purchase to match
  replace_subscription's list[str] type contract (tariff.allowed_squads
  can be None from JSON column)
2026-03-07 16:29:04 +03:00
Fringg 9e78509284 fix: handle expired subscription in guest purchase fulfillment
When a user with an expired subscription makes a landing page purchase,
the code tried to INSERT a new subscription, violating the user_id
unique constraint. Now uses replace_subscription for expired/inactive
subscriptions instead of create_paid_subscription.
2026-03-07 16:02:06 +03:00
Fringg f4ab174d32 fix: support {total_amount} placeholder in cart notification templates
Add total_amount as an alias for cart_total in format() calls so custom
locale overrides using {total_amount} are properly substituted instead
of appearing as literal text in user messages.
2026-03-07 15:50:42 +03:00
Fringg fc65e2de4c fix: use information_schema for constraint existence checks in migrations
Replace pg_class lookup with information_schema.table_constraints query
that is schema-qualified and consistent with migration 0028 pattern.
Fixes constraint detection on fresh installs where create_all() creates
constraints that pg_class lookups could miss.
2026-03-07 15:50:31 +03:00
Fringg ba335fe784 fix: use pg_class lookup for constraint existence checks in migrations
inspector.get_unique_constraints() fails to detect constraints created
by Base.metadata.create_all() on fresh installs, causing
DuplicateTableError. Query pg_class directly for reliable detection.
2026-03-07 15:38:11 +03:00
Fringg 5214f55f46 fix: drop legacy prize_days column from contest_templates
The column was left over from the old schema before prize_type/prize_value
refactoring. Its NOT NULL constraint caused INSERT failures since the
SQLAlchemy model no longer includes it.
2026-03-07 15:34:54 +03:00
firewookie a6849242ff add riopay 2026-03-07 15:35:26 +05:00
Fringg 9d5329d9d1 fix: resolve NameError in YooKassa successful payment processing
event_object was referenced in _process_successful_yookassa_payment
but never passed to the method, causing all YooKassa webhook payments
to fail. Use payment.amount_kopeks from the database model instead.
2026-03-07 13:28:25 +03:00
Fringg bbd353ff38 fix: resolve alembic migration failures on fresh database install
Migration 0001 uses Base.metadata.create_all() which creates ALL tables
from current models.py, causing subsequent migrations (0015+) to fail
with "already exists" errors when they try to re-create constraints,
indexes, columns, and tables.

Three-layer fix:

1. migrations.py: detect fresh DB (no tables) and bootstrap via
   create_all() + stamp head, bypassing all migrations entirely.

2. models.py: add EmailTemplate model, CheckConstraints to LandingPage,
   and indexes to GuestPurchase so create_all() produces a complete
   schema identical to running all 30 migrations sequentially.

3. Idempotency guards in migrations 0015-0030: _has_unique_constraint,
   _has_table, _has_index, _has_column, _has_check_constraint checks
   before DDL operations, protecting against re-runs via make migrate.
2026-03-07 13:17:04 +03:00
Fringg 11d3e637c1 feat: add configurable animated background for landing pages
Add background_config JSON field to LandingPage model, enabling
per-landing animated backgrounds (aurora, sparkles, vortex, etc).

- Add background_config column (JSON, nullable) with Alembic migration
- Add validated background_config to create/update/detail/public schemas
- Reuse ALLOWED_BG_TYPES and _validate_settings from branding module
- Strip unknown keys via whitelist, validate all fields including reducedOnMobile
2026-03-07 12:46:02 +03:00
Fringg 0ba1127469 feat: add paginated purchases list endpoint for landing pages
Add GET /admin/landings/{id}/purchases with offset/limit pagination,
optional status filter (validated against GuestPurchaseStatus enum),
tariff name join, and truncated token display.
2026-03-07 09:49:57 +03:00
Fringg 25478ced20 feat: add landing page statistics endpoint with charts data
Add GET /admin/landings/{id}/stats endpoint returning:
- Summary stats (purchases, revenue, gifts, conversion rate)
- Daily breakdown for last 30 days (purchases, revenue, gifts per day)
- Tariff distribution (purchases and revenue per tariff)

Uses case() expressions for SQLite compatibility, timezone-safe
date grouping via func.timezone('UTC', ...), and composite index
(landing_id, status, paid_at) for query performance.
2026-03-07 09:35:21 +03:00
Egor 57b95671ea Merge pull request #2686 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.24.0
2026-03-07 07:31:21 +03:00
github-actions[bot] eecf2b4183 chore(main): release 3.24.0 2026-03-07 04:30:05 +00:00
Egor 5a97fc2fa1 Merge pull request #2685 from BEDOLAGA-DEV/dev
Dev
2026-03-07 07:26:29 +03:00
Fringg 26b486cdd9 chore: sync uv.lock with pyproject.toml version bump 2026-03-07 07:18:43 +03:00
Egor ba05c5ce92 Merge pull request #2684 from BEDOLAGA-DEV/main
w
2026-03-07 07:16:05 +03:00
Fringg 372d628908 fix: remove executable bit from email_service.py 2026-03-07 07:14:52 +03:00
Fringg ceac29d5e3 style: format admin_landings.py 2026-03-07 07:13:01 +03:00
Egor 3ee108fce8 Merge pull request #2658 from thegrayfoxxx/fix_logo_from_show_qr
fix: reset QR photo when returning to referral
2026-03-07 07:09:38 +03:00
Egor de541ea1c3 Merge pull request #2682 from smediainfo/fix/email-message-id-headers
fix: add Message-ID and Date headers to outgoing emails
2026-03-07 07:08:37 +03:00
Fringg 6d65e15266 fix: read discount overrides from landing model instead of response DTO
After removing overrides from the public LandingDiscountInfo response,
_load_landing_tariffs still referenced discount.overrides which no
longer exists. Read from landing.discount_overrides directly.
2026-03-07 07:05:38 +03:00
Fringg aa7d98630d feat: add discount system for landing pages
Add time-bounded percentage discounts with per-tariff overrides
and countdown timer support for landing pages.

- Add 5 discount columns to LandingPage model (percent, overrides,
  starts_at, ends_at, badge_text) with Alembic migrations 0027-0028
- Add DB CHECK constraints for discount_percent range and date ordering
- Add discount price calculation in validate_and_calculate() and
  public landing config endpoint with consistent formula
- Add admin CRUD with Pydantic validation, cascade-clear on removal,
  merged date validation on partial updates, size bounds
- Remove discount_overrides from public API (baked into prices)
- Add size limits for allowed_tariff_ids and allowed_periods
2026-03-07 07:01:35 +03:00
Fringg 8b77cdae2c feat: add GET /admin/rbac/users endpoint for listing all RBAC users 2026-03-07 06:23:14 +03:00
Fringg c93dbec7a0 feat: add landings to permission registry
Register landings:read, landings:create, landings:edit, landings:delete
permissions so they appear in the role editor permission matrix.
2026-03-07 06:05:03 +03:00
Fringg fa21549cac fix: add activate hint to gift pending activation email link
Append ?activate=1 to success page URL in recipient notification email
for gift purchases with pending_activation status, so the frontend can
distinguish buyer from recipient and show the activate button only to
the recipient.
2026-03-07 06:00:29 +03:00
Fringg c10d6780ba feat: add external squad support for tariffs
- Add external_squad_uuid column to Tariff model with Alembic migration
- Add external_squad_uuid parameter to RemnaWave API create_user/update_user
- Pass external squad from tariff to RemnaWave on subscription creation/update
- Sync external squad in monitoring service, sync service, admin user management
- Clear external squad when tariff has none (consistent across all call sites)
- Add GET /available-external-squads endpoint with UUID validation and response model
- Update tariff schemas with UUID pattern validation
- Fix db.refresh to include tariff relationship for async safety
2026-03-07 05:44:19 +03:00
Fringg 770f19e846 fix: address review findings for guest purchase admin notifications
- Remove double html.escape on payment_method (already escaped in helper)
- Use attribute_names=['landing'] keyword arg in db.refresh for consistency
- Use async with Bot() for guaranteed session cleanup
2026-03-07 04:48:52 +03:00
Fringg dbb9757a3c feat: add admin topic notifications for landing page purchases
- New send_guest_purchase_notification() method in AdminNotificationService
  with blockquote for payment details, landing slug, buyer/recipient info
- Called from fulfill_purchase() for both DELIVERED and PENDING_ACTIVATION
- Called from activate_purchase() when pending purchase is activated
- Different titles: regular purchase, gift purchase, pending activation
- Properly typed GuestPurchase, html.escape on all user data
- Refresh purchase with ['landing'] relationship after commit
- html.escape fallback in _get_payment_method_display
- ruff formatting fixes
2026-03-07 04:44:02 +03:00
Fringg 77456efb75 fix: add X-CSRF-Token and X-Telegram-Init-Data to CORS allow_headers
The security hardening commit changed allow_headers from ['*'] to
['Authorization', 'Content-Type'], but the frontend sends X-CSRF-Token
on all POST/PUT/DELETE/PATCH requests and X-Telegram-Init-Data on all
requests. The missing headers caused preflight OPTIONS requests to fail
with 400 "Disallowed CORS origin".
2026-03-07 04:30:29 +03:00
Fringg c165cca323 fix: use get_rendered_override for proper variable substitution in guest email overrides
Admin-created email template overrides were not substituting {tariff_name},
{period_days}, {cabinet_url} etc. because get_template_override returns raw
body_html. Switched to get_rendered_override which performs variable
substitution with html.escape. Also removed dead is_existing_user from
sample context.
2026-03-07 04:26:09 +03:00
Fringg 6970340e62 feat: add quick purchase email templates to admin panel
- Register 4 guest purchase template types in admin email templates:
  guest_subscription_delivered, guest_activation_required,
  guest_gift_received, guest_cabinet_credentials
- Add sample contexts with placeholders for preview/test
- Add DB override support to send_guest_notification for all 4 types
2026-03-07 04:13:41 +03:00
Fringg 9217352685 fix: remove subscription connection links from guest purchase emails
Replace VPN subscription URLs with cabinet links in all email templates:
- GUEST_SUBSCRIPTION_DELIVERED: unified to always show cabinet link
- GUEST_GIFT_RECEIVED: replaced subscription URL with cabinet link
Both self-purchase and gift flows now only include cabinet links.
2026-03-07 04:10:27 +03:00
Fringg a539d69854 fix: code style and formatting from review
- Format long dict literal in admin_landings.py
- Add blank line after validator in admin_payment_methods.py
- Fix ruff E203 slice spacing in landing.py
- Fix long line wrapping in payment_service.py
2026-03-07 03:50:15 +03:00
Fringg e96fe1ecd8 fix: comprehensive security hardening from 7-agent review
Schema validation:
- Add max_length to init_data (4096), widget fields (first_name 64,
  last_name 64, username 32, photo_url 512, hash 64)
- Add max_length=2048 to all token fields (verify, refresh, reset, auto-login)
- Add max_length=128 to EmailLoginRequest.password (bcrypt DoS prevention)
- Add pattern to OAuthCallbackRequest.referral_code (was missing)
- Add pattern=r'^\d{6}$' to EmailChangeVerifyRequest.code
- Add pattern/max_length to language field (ISO 639-1)

Auth endpoints:
- Add user.status check to auto-login (banned users could authenticate)
- Add exception chaining (from e) to refresh and auto-login endpoints
- Add IP rate limiting to initData, register standalone, verify email,
  forgot password, and reset password endpoints

CORS:
- Add PATCH to allow_methods in both unified_app and webapi (38+ PATCH
  endpoints were blocked for cross-origin requests)

JWKS:
- Fix race condition: move force-refresh cooldown check and cache
  invalidation inside asyncio.Lock via dedicated _force_refresh_jwks()
2026-03-07 03:47:58 +03:00
Fringg 5499ad62dc fix: add referral_code pattern validation, email login rate limiting, and Retry-After headers
- Add pattern=r'^[a-zA-Z0-9_-]+$' to referral_code in TelegramAuthRequest,
  TelegramWidgetAuthRequest, and EmailRegisterStandaloneRequest for consistency
  with TelegramOIDCAuthRequest
- Add IP-based rate limiting (10 req/min) to /email/login endpoint
- Add Retry-After: 60 header to /login/auto 429 response
2026-03-07 03:37:25 +03:00
Fringg 6495384bcf fix: transaction boundary and CORS in webapi
- Revert OIDC flush to commit before _store_refresh_token
  (matches widget/initData pattern, prevents rollback losing user updates)
- Fix CORS wildcard+credentials in webapi/app.py (same as unified_app fix)
2026-03-07 03:33:37 +03:00
Fringg 5c55662e2c fix: comprehensive security and quality fixes from 7-agent review
Security:
- CORS: disable credentials when wildcard origin, restrict methods/headers
- Token replay: Redis-based id_token dedup with TTL matching expiry
- JWT secret: warn when falling back to BOT_TOKEN
- Rate limiting: add to legacy widget endpoint (was missing)
- Retry-After: add header to all 429 responses

Quality:
- Read OIDC CLIENT_ID from DB with env fallback (admin panel works)
- Consolidate db.commit() — flush mid-handler, single commit at end
- Move get_setting_value import to top-level
2026-03-07 03:27:00 +03:00
Fringg b78c01cae9 fix: critical OIDC fixes from 7-agent review
- Fix broken import (system_settings → system_setting) that crashed
  OIDC endpoint on every request
- Extract get_setting_value to shared CRUD module
- Add JWKS force-refresh cooldown (30s) to prevent abuse
- Remove dead _OIDC_TOKEN_URL constant
- Add raise from for exception chaining
- Remove unused _photo_url variable
- Add pattern validation on referral_code field
2026-03-07 03:18:20 +03:00
Fringg 2405dc5c1b fix: read OIDC enabled setting from DB in auth endpoint
Match branding endpoint pattern — check system_settings first,
fall back to env var, ensuring admin panel toggle takes effect.
2026-03-07 03:04:42 +03:00
Fringg da1cc4fe5a fix: address code review findings for Telegram OIDC
- JWKS cache: add asyncio.Lock to prevent thundering herd, extract
  _build_public_keys helper, retry JWKS fetch on kid mismatch (key rotation)
- Remove dead code: exchange_telegram_oidc_code (unused, popup sends id_token directly)
- OIDC auth endpoint: add rate limiting, fix int() parse with try/except,
  extract last_name/photo_url/language from claims, narrow bare Exception
  to (ValueError, LookupError)
- Schema: add max_length=4096 to id_token field
- Branding: read TELEGRAM_OIDC_ENABLED from DB settings with env fallback
2026-03-07 02:54:29 +03:00
Fringg 000b0c0592 feat: expose oidc_enabled and oidc_client_id in telegram-widget config 2026-03-07 02:33:52 +03:00
Fringg 3a400d9f8b feat: add POST /auth/telegram/oidc endpoint for OIDC popup flow 2026-03-07 02:32:40 +03:00
Fringg 2f0a9dc4f3 feat: add Telegram OIDC id_token validation and code exchange 2026-03-07 02:30:33 +03:00
Fringg 3a361628aa feat: register TELEGRAM_OIDC category, hints in admin settings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:25:54 +03:00
Fringg 833df518d0 feat: add TELEGRAM_OIDC_* settings for new Telegram Login
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:24:13 +03:00
Fringg 084a3cd16f feat: configurable Telegram Login Widget with admin settings
- Add 4 TELEGRAM_WIDGET_* settings to config (size, radius, userpic, request_access)
- Register TELEGRAM_WIDGET category with choices, hints, and prefix mapping
- Add public GET /branding/telegram-widget endpoint returning widget config
- Use Literal type for size validation, Field(ge=0, le=20) for radius bounds
- Clamp radius values from DB to prevent out-of-range values
2026-03-07 01:48:14 +03:00
Fringg 694aeccc31 fix: remove decorative cloudpayments sub-options
CloudPayments doesn't support programmatic card/sbp routing — the user
selects the payment method on the provider's payment page. Remove
available_sub_options so no misleading choice is shown on landing pages.
2026-03-07 00:04:32 +03:00
Fringg 5f01783dcb fix: validate payment sub-option suffix and harden payment method handling
- Add regex pattern + max_length constraint on payment_method field
- Validate sub-option suffix against known available_sub_options
- Validate sub-option is enabled on the landing (not disabled via config)
- Sort methods by ID length desc to prevent freekassa/freekassa_sbp ambiguity
- Accept yookassa_card and cloudpayments_card/sbp in create_guest_payment
- Send single sub-option to frontend (not just when >1) for correct routing
2026-03-07 00:01:44 +03:00
Fringg c53e9af744 feat: expose payment sub-options with labels in public landing API
Resolve sub-option display names from payment_method_config_service
and return them as a list of {id, name} in the public landing config.
Accept suffixed payment_method IDs (e.g. platega_2, yookassa_sbp)
in the purchase endpoint for sub-option selection.
2026-03-06 23:53:28 +03:00
Fringg 220196fb7a feat: add sub_options support for landing page payment methods
Allow per-landing override of payment method sub-options (e.g. Card/SBP
for Yookassa). Add validated sub_options field to admin and public schemas
with opt-out model (missing keys = enabled, null = all available).
2026-03-06 23:30:44 +03:00
sMedia.tech e9b4d8e444 fix: add Message-ID and Date headers to outgoing emails
Without these RFC 5322 required headers, Postfix sends messages with
empty message-id=<> which triggers spam filters at receiving MTAs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:53:39 +03:00
Fringg d86c29a5d3 fix: preserve connected_squads during subscription replacement cleanup
Root cause: validate_and_clean_subscription() wiped connected_squads=[]
before create_remnawave_user() could send them to the Remnawave panel.
This caused replaced subscriptions to lose squad (server) assignments.

Also: add update_server_counters=True to all guest purchase flows,
commit tariff_id before create_remnawave_user to ensure fresh ORM state.
2026-03-06 22:28:39 +03:00
Fringg 8510597ddb fix: add pending_activation to purchase stats and show total count 2026-03-06 22:16:28 +03:00
Fringg f8edfd7746 feat: guest purchase → cabinet account integration
Create verified cabinet accounts for email-based guest purchasers:
- Auto-generate password for new/existing users without password_hash
- Auto-login JWT token (72h TTL) stored once at fulfillment
- POST /login/auto endpoint with rate limiting (5 req/60s)
- Credentials email template (5 languages)
- Fix forgot_password for guest-created email users
- Clear plaintext password from DB after email delivery
- TTL-capped credential exposure (24h delivered, 72h pending_activation)
- DB cleanup of expired credentials on poll
2026-03-06 21:59:36 +03:00
Fringg 776fc3aadc feat: guest purchase delivery & activation system
- Add PENDING_ACTIVATION status for users with existing subscriptions
- Add activation endpoint POST /landing/activate/{token}
- Send email notifications on delivery and pending activation
- Add 3 email templates (delivered, activation required, gift received) in 5 languages
- Extract purchase status response builder to reusable helper
- Move activation logic to service layer
- Add header injection protection in email service
- Add Literal type guard for contact_type parameter
- Fix _mask_email crash on malformed input
- Pre-resolve notification params before commit to avoid DetachedInstanceError
2026-03-06 19:56:37 +03:00
Fringg b85646af85 fix: pass return_url to all payment providers for guest purchases
Platega, Heleket, WATA, CloudPayments now accept optional return_url
and pass it to their APIs. Guest payments redirect back to cabinet
success page instead of Telegram bot.
2026-03-06 18:18:42 +03:00
Fringg e0f2243f49 fix: make users.promo_group_id nullable — sync DB with model
Migration 0023: ALTER COLUMN promo_group_id SET nullable=True.
Fixes NOT NULL violation when creating guest users during landing purchases.
2026-03-06 18:18:40 +03:00
Fringg ab981dce0d fix: treat empty icon_url as None in payment method validation 2026-03-06 18:00:34 +03:00
Fringg 6f871edc9d fix: CryptoBot guest payment — remove is_paid @property write, use correct status
- Remove `locked.is_paid = True` — is_paid is a read-only @property computed from status
- Change status from 'completed' to 'paid' (matching is_paid property check)
- Use db.commit() instead of db.flush() for guest payment persistence
2026-03-06 16:54:25 +03:00
Fringg 3d3bb3badb fix: миграция 0021 — drop server_default перед сменой типа на JSON
PostgreSQL не может автоматически привести строковый default к типу JSON.
Решение: убрать default, сменить тип, затем поставить новый default через raw SQL.
2026-03-06 16:45:03 +03:00
Fringg 6deab7dd8c feat: мультиязычные лендинги + гостевые платежи для всех провайдеров
Мультиязычность:
- Миграция 0021: текстовые поля лендингов → JSON с ключами локалей
- Утилита resolve_locale_text с fallback-цепочкой (lang → ru → en → first)
- Админ API: dict[str, str] для title/subtitle/footer/meta/features
- Публичный API: ?lang= параметр, резолвит в плоские строки
- Обратная совместимость: plain strings → {"ru": value}

Гостевые платежи:
- Миграция 0022: user_id nullable во всех платёжных таблицах
- Поддержка всех провайдеров кроме Stars
- Общий хелпер try_fulfill_guest_purchase в common.py
- YooKassa переведена на общий хелпер

Исправления по ревью:
- CryptoBot: guest fulfillment после FOR UPDATE lock
- _patch_guest_metadata: commit вместо flush
- Freekassa/KassaAI: metadata как dict вместо JSON-строки
- purchase_token маскирован в логах
- None → "guest" в order_id
- Rate limit на GET /landing/{slug}
- Маскирование contact_value
2026-03-06 16:10:09 +03:00
firewookie 861ffe5424 fix migration 2026-03-06 12:36:32 +05:00
firewookie 06ccf4b275 webhook install bugfix + add info in readme 2026-03-06 11:56:15 +05:00
firewookie 7ed91b13eb - RioPay payment system integration 2026-03-06 11:44:22 +05:00
firewookie 319f49435a - Карточки "Бонус новому пользователю" и "Бонус пригласившему"
скрыты когда значение = 0
- Динамический grid-cols в зависимости от количества видимых карточек
- Добавлено поле max_commission_payments в тип ReferralTerms
- Уведомления бота: строки с бонусом нового пользователя и бонусом
    пригласившего скрываются если соответствующие настройки = 0
- Раздел "Как работают награды": карточки бонусов скрыты при значении 0
- Invite message: строка про бонус за первое пополнение скрыта при 0
- Текст комиссии: "с каждого пополнения" при без лимита,
    "с пополнений" при наличии REFERRAL_MAX_COMMISSION_PAYMENTS- API /terms: добавлено поле max_commission_payments
- Добавлен ключ локали REFERRAL_REWARD_COMMISSION_LIMITED (ru/en/ua/zh/fa)
2026-03-06 11:08:52 +05:00
firewookie 23761a74f2 fix formatting 2026-03-06 09:57:24 +05:00
firewookie 8620aaedb1 fix formatting 2026-03-06 09:54:55 +05:00
firewookie aaffc26a90 - Интеграция рекурентов от Юкассы
- Багфикс личного кабинета
2026-03-06 09:47:58 +05:00
Fringg ef450955e6 fix: безопасность и качество кода лендингов — 16 исправлений
- CRITICAL: блокировка fulfillment при несовпадении суммы
- CRITICAL: верификация суммы webhook перед фулфилментом
- CRITICAL: TTL 24ч на доступ к subscription_url + rate limit статуса
- HIGH: IntegrityError для telegram-пользователей (race condition)
- HIGH: валидация icon_url (HTTPS/relative only)
- HIGH: строгий whitelist setattr для update_purchase_status
- HIGH: SAVEPOINT вместо full rollback в _find_or_create_user
- HIGH: отложенный commit покупки до успеха платежа
- MEDIUM: N+1 запрос в списке лендингов → batch stats
- MEDIUM: лимиты длины текстовых полей в схемах
- MEDIUM: строгий email regex
- MEDIUM: индекс на guest_purchases.landing_id (миграция 0020)
- LOW: token prefix 5 символов, расширенные reserved slugs
2026-03-06 07:22:48 +03:00
Fringg 5e404cc082 feat: публичные лендинг-страницы для быстрой покупки VPN-подписок
- Модели LandingPage и GuestPurchase + миграции 0018/0019
- CRUD для лендингов и гостевых покупок
- Публичные роуты: GET /{slug}, POST /{slug}/purchase, GET /purchase/{token}
- Админ-роуты: CRUD лендингов с RBAC (manage_landings)
- Сервис guest_purchase_service: валидация, создание, фулфилмент
- Интеграция с PaymentService (YooKassa card/SBP) для гостевых платежей
- Webhook-обработка с идемпотентностью и атомарными транзакциями
- Rate limiting на публичных эндпоинтах
- YooKassaPayment.user_id теперь nullable для гостевых платежей
2026-03-06 07:02:42 +03:00
Egor c669c5951a Merge pull request #2676 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.23.2
2026-03-06 05:33:40 +03:00
github-actions[bot] b68c1c751a chore(main): release 3.23.2 2026-03-06 02:33:15 +00:00
Egor 0c0e219691 Merge pull request #2673 from BEDOLAGA-DEV/dev
Dev
2026-03-06 05:32:55 +03:00
Egor 8eb6a8c460 Merge pull request #2675 from BEDOLAGA-DEV/main
fix: sync uv.lock version with pyproject.toml 3.23.1
2026-03-06 05:27:56 +03:00
Fringg bc52fd2711 fix: sync uv.lock version with pyproject.toml 3.23.1
release-please обновляет version в pyproject.toml, но не
перегенерирует uv.lock — Docker build падает на uv sync --locked.
2026-03-06 05:27:05 +03:00
Egor 6da408fe15 Merge pull request #2672 from BEDOLAGA-DEV/main
w
2026-03-06 05:22:22 +03:00
Fringg 15fe45d113 fix: миграция 0016 падает если FK constraint отсутствует в БД
Вместо хардкода имён constraint'ов — ищем реальное имя через
pg_constraint. Пропускаем несуществующие таблицы и FK.
Исправляет краш при обновлении у пользователей с неполной схемой.
2026-03-06 05:17:51 +03:00
Fringg 3e26832e74 fix: device_limit fallback 1→0 для корректного отображения безлимита
Значение 0 означает «без ограничений» — фронтенд показывает ∞.
Старый fallback=1 некорректно ограничивал подписки без лимита.
2026-03-06 05:17:44 +03:00
anatoliy 1afcd84e0e fix: photo handling in QR messages
Add check to ensure photo is only used for non-QR messages
2026-03-02 23:57:34 +03:00
218 changed files with 19121 additions and 4411 deletions
+21
View File
@@ -369,6 +369,8 @@ REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
REFERRAL_COMMISSION_PERCENT=25
# Макс. кол-во платежей реферала, с которых начисляется комиссия (0 = без лимита)
REFERRAL_MAX_COMMISSION_PAYMENTS=0
# Показывать раздел партнёрки в кабинете
REFERRAL_PARTNER_SECTION_VISIBLE=true
@@ -492,6 +494,11 @@ YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
# Быстрый выбор суммы пополнения через YooKassa
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Рекуррентные платежи YooKassa (автосохранение карты для автоплатежей)
YOOKASSA_RECURRENT_ENABLED=false
# true = карта сохраняется обязательно, false = пользователь решает (чекбокс на стороне YooKassa)
YOOKASSA_RECURRENT_REQUIRED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
# Отключить пополнение баланса через поддержку
@@ -655,6 +662,20 @@ KASSA_AI_WEBHOOK_PORT=8089
# Способ оплаты: 44 = СБП (QR), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID=44
# ===== RIOPAY (api.riopay.online) =====
RIOPAY_ENABLED=false
RIOPAY_API_TOKEN=
# Ключ для HMAC-SHA512 верификации вебхуков (если не указан, используется RIOPAY_API_TOKEN)
RIOPAY_WEBHOOK_SECRET=
RIOPAY_DISPLAY_NAME=RioPay
RIOPAY_CURRENCY=RUB
RIOPAY_MIN_AMOUNT_KOPEKS=10000
RIOPAY_MAX_AMOUNT_KOPEKS=100000000
RIOPAY_WEBHOOK_PATH=/riopay-webhook
# URL для редиректа после оплаты (опционально)
RIOPAY_SUCCESS_URL=
RIOPAY_FAIL_URL=
# ===== WATA =====
WATA_ENABLED=false
WATA_BASE_URL=https://api.wata.pro
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.23.1"
".": "3.32.1"
}
+641
View File
@@ -1,5 +1,646 @@
# Changelog
## [3.32.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.0...v3.32.1) (2026-03-13)
### Bug Fixes
* invalid ISO date format in node usage stats API call ([69a38da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69a38dad259bd05f4658e1014ce0bd73fc2e2ac5))
* platega webhook ID fallback for SBP and card payments ([aa3459b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa3459b8463ce0a54b7709aa3547b2337064fa26))
* resolve MissingGreenlet in switch_tariff endpoint ([4d695be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4d695be7d51adda40fa72c00c349fb0e1ec4acd2))
## [3.32.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.31.0...v3.32.0) (2026-03-13)
### New Features
* add _calculate_servers_price (fixed fallback) and _calculate_traffic_price ([88369ee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/88369eec5047e733d26d2450a74abd0d600b2e1b))
* add CLASSIC_PERIOD_PRICES to config ([c3bb63f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c3bb63ffed6e0b684c322aa51d70ab7e71c8eb6b))
* add LIMITED subscription status and preserve extra devices on tariff switch ([8f43452](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f434525eb14618e3c3e26261d443b1632c111bb))
* add RenewalPricing dataclass and PricingEngine discount methods ([83ca51c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/83ca51cd5b040e747c6db904dde0f3a5c59f480f))
* implement calculate_renewal_price with tariff and classic modes ([02e5401](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e5401327786c9dfe5ae7d4c89624c9455aa53e))
### Bug Fixes
* add missing settings import in admin_users tariff switch ([b2ee6c7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2ee6c766a1fb0c9a701684a6349b970d12f5e2e))
* add per-category discounts and months multiplier to classic mode ([1660b24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1660b24f9844374bbd156f9202a8e1550a6beb49))
* add period_days whitelist validation and type annotations ([18e2e78](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/18e2e7841a6d614263e7c87db5964916ec869a9d))
* address 6-agent review findings for PricingEngine ([c9f2dff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c9f2dffabf6369df360c5f9ad7a12c0415026310))
* address review findings from 5-agent audit ([08bea70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/08bea704ded78102dce29deac8da95c4e4b9d815))
* atomicity refactor, review fixes, and DELETED recovery logging ([ba54819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba54819f9cd7f60914dd472b68885683f435db4e))
* change None assignment to [] + add "or []" guards at all 5 call sites. ([a5fbd74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a5fbd7400f828824c5baa520bdaf06023b4caf70))
* downgrade known-harmless RemnaWave 400s to warning level ([0419781](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04197817fede058dc4688dce2f9877f0fc2a7f7f))
* guard rollback on commit flag, add flush to promo_offer_log ([b7775b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b7775b72dc7a1b3d18f179c2f247fe9f47023347))
* handle legacy telegram_id in YooKassa webhook recovery metadata ([815a1d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/815a1d9136f39b932d4b369aec9d67034d6785d9))
* harden remnawave API error handling and YooKassa user cross-validation ([585baaf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/585baaf63c9f535e5311a32085d0187d8c854001))
* harden YooKassa webhook recovery user lookup ([d35ee58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d35ee58aa6f3edc6a9e8ab43025569262acf64a2))
* payment providers — lock_user_for_update + commit=False atomicity ([b4ef52c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b4ef52caa4b324eded8e6c6cb715a09ad59140c1))
* prevent balance loss on auto-purchase for DISABLED subscriptions and fix WATA expiration ([266340a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/266340aad195995f208ed82fc11e0909d34898f4))
* pricing audit — display/charge parity, race conditions, balance locks ([ae99358](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ae99358ae9f35a25370ab127d98a0b630a08e3f2))
* resolve merge conflict with dev (accept calc_device_limit_on_tariff_switch) ([ba049ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba049ca017e004c25f8738f01b2d5f329a35bb5e))
* user deletion FK error + connected_squads None TypeError ([a5fbd74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a5fbd7400f828824c5baa520bdaf06023b4caf70))
### Refactoring
* add typed breakdowns + module-level singleton to PricingEngine ([b551def](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b551def3402e2bf762406fb0b374360958231bb3))
* extract shared formatting helpers into app/utils/formatting.py ([5e9a462](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5e9a462261e46ee649de481266a821fd6793bf2e))
* make finalize() accept both old and new pricing types ([3efa24b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3efa24bab3a2bd1d31103b134e502c10af8e41e1))
* migrate admin user price calculation to PricingEngine ([49c0f3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49c0f3fc10d27092961601cf7f6a780fb56885fa))
* migrate all callers to pricing_engine singleton + fix miniapp discount ([e24b911](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e24b911283bf4cbee7b18d3e49c935217e4a2863))
* migrate bot renewal display to PricingEngine ([ce82c2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ce82c2c00988542ac73dc9d2e811711ea9cefebe))
* migrate bot renewal execute to PricingEngine ([acf27a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acf27a102308d38676b2ccaef78016b56a80935d))
* migrate cabinet renewal display + execute to PricingEngine ([28fc36d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28fc36dca41b626430baf823274561269023ac59))
* migrate cart auto-purchase to PricingEngine (fresh calc) ([bd2e93a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd2e93a6a5076341b104f7dba2b7fc5fdb587e66))
* migrate menu.py renewal pricing to PricingEngine ([652b6da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/652b6dabde014d19075f13198dd06e5fb8bef380))
* migrate miniapp renewal display + execute to PricingEngine ([cb43aca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb43acab3194bbf9b6e2c04ca0254ba5b2571b2d))
* migrate recurrent and monitoring services to PricingEngine ([978f68e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/978f68e7be42b0faf92d9ac5bc0bbaa2022ac95b))
* migrate remaining callers to PricingEngine + cleanup dead CRUD ([75dbd2b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/75dbd2b4fcc8ab14ac44d915bf55b10406544bb1))
* migrate try_auto_extend_expired to PricingEngine ([e6ebc67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e6ebc6722d826d291156e6bff3bf86000b32b783))
* remove dead pricing code and fix miniapp classic mode ([c9a9816](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c9a9816daa15a4534a3990822543eeefe1a1631b))
* unify first-purchase discount algorithm with PricingEngine ([fe4e6ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe4e6acb5391d0797ea01281eeb2e2ea59a0070f))
## [3.31.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.30.0...v3.31.0) (2026-03-12)
### New Features
* add show_in_gift toggle for tariffs in admin panel ([cb5126a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb5126aff8c15938a59ea9c4f8e605b250b05dbc))
* add sync-squads endpoint for bulk updating subscription squads in Remnawave ([b1e2146](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b1e2146254255586b5be9bd894ac4d113a0a8cf5))
* auto-sync squads to Remnawave when admin updates tariff ([076290e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/076290e0c1d81b610a7653d6b64ed218e0f124b4))
* referral links now point to web cabinet instead of bot ([12ae871](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/12ae871653399bc4ccd23b6394878e814ce9cd75))
### Bug Fixes
* add post_update=True to User.referrals self-referential relationship ([9957259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/995725988150f31d193631120a4692e88fa4dd57))
* add Telegram Stars payment support for gift subscriptions ([5424d8c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5424d8c31484873b0adc0bc980abdc51ee81325b))
* correct skipped_count in sync-squads circuit breaker and simplify ternary ([8a362db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a362db7833b5b7793b5b52345d227cb84cbc39e))
* preserve purchased devices when admin changes user tariff ([bf72f24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf72f241d81e4432f50a61ec3bb829d18c92955d))
* prevent account takeover via auto_login_token, ensure promo group on all purchase paths ([b3f3eba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b3f3eba5756404df9ed0f12d8048244ca536f7d3))
* reactivate subscription after traffic top-up when status is EXPIRED ([8b35428](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b354280558a5f28d1b99eae55ccd21a4af6a07b))
* update promo group via M2M table so admin changes persist ([68bc8eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68bc8eb57c792059d2be8a8fff6bba3254d3773d))
### Refactoring
* remove estimated price from balance, simplify server sync, fix HTML injection ([a798f11](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a798f1143eebf52e18254bddd610f7f14a0c4056))
## [3.30.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.29.0...v3.30.0) (2026-03-11)
### New Features
* add gifts section to admin user detail API ([bca8bab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bca8bab4336b2583da9be8c642985e6a0151e33d))
* add promo group and promo offer discounts to gift subscriptions ([2fd0f6a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2fd0f6aa4eb62f704208c1e56a6542d3967e7867))
### Bug Fixes
* record transactions for free tariff switches and admin tariff changes ([864a4ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/864a4ed7005195ff3be3a8bb2e7666bc5a7f3e4e))
* reset subscription for paid users, trial-to-paid tariff conversion, gift purchase MissingGreenlet ([e67b8e4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e67b8e448e5396ee6daa8c6278bb5a0b313dda74))
* use keyword args for Path.mkdir in asyncio.to_thread ([2879996](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/287999645506a49b6693a184757598e1cdceb4d8))
## [3.29.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.28.1...v3.29.0) (2026-03-10)
### New Features
* gift subscription code-only purchase + activation via deep link ([5ffce17](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ffce175dcb8aebf22cf536bfa032c66da284600))
* prevent self-activation of gift codes ([b30c73c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b30c73c300019646ea4a0d7e1bf758464ee58f0f))
### Bug Fixes
* 3 bugs — notification type, referral with channel sub, BOT_USERNAME ([3c96c2a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3c96c2affd5a803311e3c0c9a0f844d2217f387a))
* 3 critical issues from second-round review ([a90d2d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a90d2d936793daaadf116cf314b736a9ebfb7c3b))
* add minimum 8-char length check for gift token in bot deep link ([8a8337f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a8337f538c3fcaf84b84c34b7a1e38a4ce9d580))
* address review findings from 6-agent audit ([5c34656](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5c3465647639d6f07432bc3d725bba9396af6c45))
* code-only gifts skip fulfillment in gateway webhook + retry service ([05bcac5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/05bcac502efb1b4298a1c6be91bba5d7c057b9f0))
* panel sync now updates end_date in both directions ([def594b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/def594bbb55ef45d3df81524bd8841de73a07340))
* pass full token to svc_activate instead of truncated prefix ([38c6adf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38c6adfdb4d4fc786bf6ca34a5d54025126130c0))
* refresh user subscription after gift activation in /start ([363ccce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/363ccce56d3e61554ca49d725322a79b05bc65d3))
* remove begin_nested that breaks activate_purchase transaction ([0005d59](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0005d59da1e58c38561c8346db89d8475a25d7df))
* stars rate rounding + device/traffic purchase stats ([641ff86](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/641ff86bf6f1ac1f22146f4344beda05759869fc))
* support prefix-based gift code lookup for activation ([4fb72ae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4fb72ae6e3bc65d93ab84b594b4ff5b4856c5357))
### Refactoring
* deduplicate gift activation in start.py ([769d3a0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/769d3a0b309fb6be1c3175cd66a0df7cb6e2fb67))
* rename GIFTCODE_ start parameter prefix to GIFT_ ([42b6c80](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/42b6c80a48ad0100d5ddbbe99a096ebb7b292f08))
## [3.28.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.28.0...v3.28.1) (2026-03-10)
### Bug Fixes
* migrate pricing to days-based proration, fix promo revenue leaks, fix admin panel bugs ([fcdeff1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcdeff1ee5155c88c634e12a703159c221d66af5))
## [3.28.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.27.0...v3.28.0) (2026-03-09)
### New Features
* add cabinet gift subscription API routes and schemas ([6a61b09](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a61b095755885ff8973eb9ac4422740d07e0306))
* add cabinet menu layout editor with row arrangement, custom URL buttons, and drag-and-drop reordering ([dd8d7f6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dd8d7f69203490553d15dcdad6dda28fab02d593))
* add CABINET_GIFT_ENABLED branding toggle ([759bfe1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/759bfe1bdb3a3d3f917334fd32d0ea2f5be5d1f0))
* add open_in setting for custom buttons (external browser / webapp) ([497a8ee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/497a8ee5b528cf80d7042a7eec62369b6a327339))
* add source and buyer_user_id fields to GuestPurchase model ([0936d4a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0936d4a7f651a1fcef8c2f86818320af3764b423))
* implement gateway payment for gifts, persist recipient warning ([cd04f3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cd04f3b622444f45e2edf4a92da581f3d1f79b67))
### Bug Fixes
* enforce HTTPS for webapp mode, deduplicate keyboard builder, fix long line ([69dbd6a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69dbd6a2df4cf5e0dd7156ca0f3beb53c4a061af))
* harden gift subscription feature after multi-agent review ([6a4140e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a4140e3e203beb20cc56aa9c65dfed70f0a12d7))
* loyalty tiers current status based on spending, not assigned group ([b815abf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b815abf2b11e32eb658f9a8a63ae902bc0db46f4))
* negate GIFT_PAYMENT amounts and remove dead code ([f80b058](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f80b0583804f27c322a4eb27f0613163ca1f97e9))
* normalize threshold 0→NULL in create_promo_group for consistency ([b9089e6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b9089e693f823e3b8618d08329ccba559592dfa3))
* payment gateway issues — YooKassa polling, PAL24 card 500 ([95a32e8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/95a32e8574320eeba9276e44551a2f1207ae1e8b))
* support Telegram OIDC id_token in account linking endpoint ([680c22c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/680c22c0179253d24f7f89e115a283dac92f9a49))
## [3.27.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.26.0...v3.27.0) (2026-03-09)
### New Features
* auto-resume disabled daily subscriptions on balance topup ([770b31d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/770b31d3d05c22411b64ddbea3c304e34d879f5b))
### Bug Fixes
* add method query param to return_url and latest-payment endpoint ([32d58b0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/32d58b04b9a37473f43ae07cc32d4e18b161e3b9))
* add table existence guards to migrations for optional payment tables ([f4a7763](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f4a776319eaccbce108a1f22462da6cd592fe0f3))
* admin tariff server selection - 64-byte overflow and callback routing conflicts ([536525c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/536525c9c0a7701321bc3b83d6cef125c6f343ba))
* align tariff pricing with calculate_renewal_price reference ([6349b2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6349b2f4426abd49e3bd63364f3d2b204a486282))
* conditional log messages and sanitize panel_error in user deletion ([289cbe9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/289cbe966e42afe74c8d1b936139941ff84e008b))
* encode payment status in provider return URLs and wire failed_url ([275f249](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/275f249bbdf28d065e1b856e4d8ec7e73af4e1aa))
* enforce tariff device_price and max_device_limit across all purchase paths ([f9f07f3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f9f07f360c36ce1eade8a27fa0fa5bf22808db93))
* keep DB session alive in Tribute payment notification handler ([4186159](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4186159a61a40003454afc9c0faf848582cfb037))
* latest-payment endpoint returns all payments, not just pending ([7a9264b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7a9264b1731cf8935c9e3985f41a1df919dfbf83))
* pass cabinet return_url to payment providers for top-up redirects ([7ca9619](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7ca96195a7240ce0c3bd613c20344d79e5219c74))
* propagate tariff squad changes to existing subscriptions and fix user deletion from Remnawave ([7ccfb66](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7ccfb66690c93df0c9c694935b16a280ca8ae812))
* renewal cost estimate double-counts servers and traffic in tariff mode ([bfbefeb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfbefeb1e20a191f604bbcbc79b14d8c6e4cd5bd))
* resolve concurrent AsyncSession bug and sanitize error responses ([4a5cacd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4a5cacda386e7fa60ad7b6393aa3372384bee128))
* use parsed HTML length for Telegram caption limit checks ([2649e12](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2649e12f64b8f825a3db85b95da6a335b0f8eec6))
### Refactoring
* move squad propagation to service layer with parallel Remnawave sync ([79161ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79161eaae4d67c82c45b6ea3654c0b15c8b785a4))
## [3.26.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.25.0...v3.26.0) (2026-03-08)
### New Features
* add telegram gift notification with inline activation button ([9ba61a0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ba61a08796fbc06e0dea2ee9cb02edc4126b335))
### Bug Fixes
* auto-purchase classic extend missing device_limit and traffic_limit_gb ([7dc5e4a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7dc5e4ab94a415dc739a49c74cde511aad0cbb29))
* gift purchase notification and activation flow ([330d1cb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/330d1cb6fe2eee81a3e8f841de75d41e8b4cde40))
* multiple payment and notification bugs ([f4eeb9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f4eeb9a503d6da8a152f8cb60b89f7ebbdf41c4a))
* quick topup buttons include device/server/traffic costs, broadcast button crash on media messages ([5ebe107](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ebe1072c9c8a1dcb6ee4cbbea2dc55211b534c4))
* remove is_active_paid_subscription guard from admin deactivation ([1f664a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f664a9083d81bc4462c30bf0626a44b5a30f03e))
* respect send_before_menu flag for pinned messages during new user registration ([20727b1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20727b1017457769feccccf83e99523c19026a7e))
## [3.25.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.24.0...v3.25.0) (2026-03-07)
### New Features
* add configurable animated background for landing pages ([11d3e63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11d3e637c106590a73ed804fc762bf303b37dd62))
* add landing page statistics endpoint with charts data ([25478ce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25478ced209fdbf12f2c398ad1c8d48ac26c923e))
* add paginated purchases list endpoint for landing pages ([0ba1127](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ba112746913bb9927338a7554370ac8c4e12039))
### Bug Fixes
* add or [] guard to remaining connected_squads call site in fulfill_purchase ([d9f9f3d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9f9f3dca126963967782160766bd5b26bde7a49))
* align context_vars and SAMPLE_CONTEXTS with actual runtime context keys ([ab5313a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ab5313a381f8175346b470d6d1df54d8d7d11ff8))
* align subscription_renewed/activated context_vars with runtime keys ([c507634](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c507634398d3e934246b2c66183ebad1b9949769))
* correct device_limit and connected_squads in guest purchase fulfillment ([44d46fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/44d46feb0adec9255fbf167e9937ea70b711e289))
* drop legacy prize_days column from contest_templates ([5214f55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5214f55f46391c7870a38ea51c2efc2f4e518f58))
* handle expired subscription in guest purchase fulfillment ([9e78509](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9e785092843cf7f6b3ebb5bfa1710030c74ceafb))
* remaining context_vars/SAMPLE_CONTEXTS mismatches found by agents ([d72ea6b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d72ea6b7f999c320038f0327a0c541d1cd276244))
* resolve alembic migration failures on fresh database install ([bbd353f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bbd353ff38af57aa9a8f15c60bded3259a3e3e26))
* resolve NameError in YooKassa successful payment processing ([9d5329d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d5329d9d1051eeaf77cfea4932557cdfbf21cc6))
* strip newlines from subject substitution, fix subscription notification context ([c9ea2b1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c9ea2b15e9d670d6e1888c0396a145713ec749c0))
* substitute context variables in email template overrides ([d52c87b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d52c87b2b752d3f432096318ac2ec4f9ad792929))
* substitute sample context in admin test email for template overrides ([351d714](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/351d714f2d4c200e8ab4263ff006ae33ec062d95))
* support {total_amount} placeholder in cart notification templates ([f4ab174](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f4ab174d32be8b48470320c27c92e75fdabd6d58))
* use --frozen instead of --locked in Dockerfile to avoid version mismatch ([923b36a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/923b36a8b9caed5db1c147a5ef4c001f66f8170a))
* use information_schema for constraint existence checks in migrations ([fc65e2d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fc65e2de4c9c08e7df1886c458b53f7a05894934))
* use pg_class lookup for constraint existence checks in migrations ([ba335fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba335fe78430e26b9e2449dbbd6db209557698e0))
## [3.24.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.23.2...v3.24.0) (2026-03-07)
### New Features
* account linking and merge system for cabinet ([dc7b8dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc7b8dc72a3a398d6270a0a2b8ce9e2b54cb9af7))
* account merge system — atomic user merge with full FK coverage ([2664b49](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2664b4956d8436a2720d7cd5992b8cdbb72cdbd9))
* add 'default' (no color) option for button styles ([10538e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10538e735149bf3f3f2029ff44b94d11d48c478e))
* add admin campaign chart data endpoint with deposits/spending split ([fa7de58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa7de589c1bd0ae37ebaaa07bae0ed3d68e01720))
* add admin notifications for partner applications and withdrawals ([cf7cc5a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf7cc5a84e295608009f255fcd0dcedb5a2a04a3))
* add admin partner settings API (withdrawal toggle, requisites text, partner visibility) ([6881d97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6881d97bbb1f6cd8ca3609c2d9286a6e4fb24fc3))
* add admin sales statistics API with 6 analytics endpoints ([58faf9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58faf9eaeca63c458093d2a5e74a860f57712ab0))
* add admin topic notifications for landing page purchases ([dbb9757](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dbb9757a3c7938ab7505358942f675b82401245a))
* add all remaining RemnaWave webhook events (node, service, crm, device) ([1e37fd9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e37fd9dd271814e644af591343cada6ab12d612))
* add button style and emoji support for cabinet mode (Bot API 9.4) ([bf2b2f1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf2b2f1c5650e527fcac0fb3e72b4e6e19bef406))
* add cabinet admin API for pinned messages management ([1a476c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1a476c49c19d1ec2ab2cda1c2ffb5fd242288bb6))
* add campaign_id to ReferralEarning for campaign attribution ([0c07812](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c07812ecc9502f54a7745a77b086fc52bdc0e34))
* add ChatTypeFilterMiddleware to ignore group/forum messages ([25f014f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25f014fd8988b5513fba8fec4483981384687e96))
* add close button to all webhook notifications ([d9de15a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9de15a5a06aec3901415bdfd25b55d2ca01d28c))
* add daily deposits by payment method breakdown ([d33c5d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d33c5d6c07ce4a9efaf3c5aceb448e968e1b8ed7))
* add daily device purchases chart to addons stats ([2449a5c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2449a5cbbe5179a762197414a5752896383a6ee4))
* add dedicated sales_stats RBAC permission section ([8f29e2e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f29e2eee2e0c78f7f7e87a322eaf4bd4221069c))
* add desired commission percent to partner application ([7ea8fbd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7ea8fbd584aff2127595001094ef69acb52f847f))
* add discount system for landing pages ([aa7d986](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa7d98630dd9be2cfb81dac3ef2c1c6730487e61))
* add external squad support for tariffs ([c10d678](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c10d6780ba89ac641769dcb0c4ab2d89f124f0b7))
* add GET /admin/rbac/users endpoint for listing all RBAC users ([8b77cda](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b77cdae2ccc489bfead89523f31cd15bfdc675b))
* add granular user permissions (balance, subscription, promo_group, referral, send_offer) ([60c4fe2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/60c4fe2e239d8fef7726cac769711c8fcce789eb))
* add landings to permission registry ([c93dbec](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c93dbec7a0e24a6cc41449ed3c6e5fb669b127a9))
* add lite mode functionality with endpoints for retrieval and update ([7b0403a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b0403a307702c24efefc5c14af8cb2fb7525671))
* add LOG_COLORS env setting to toggle console ANSI colors ([27309f5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27309f53d9fa0ba9a2ca07a65feed96bf38f470c))
* add MULENPAY_WEBSITE_URL setting for post-payment redirect ([fe5f5de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe5f5ded965e36300e1c73f25f16de22f84651ad))
* add multi-channel mandatory subscription system ([8375d7e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8375d7ecc5e54ea935a00175dd26f667eab95346))
* add partner system and withdrawal management to cabinet ([58bfaea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58bfaeaddbcbb98cb67dbd507847a0e5c8d07809))
* add per-button enable/disable toggle and custom labels per locale ([68773b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68773b7e77aa344d18b0f304fa561c91d7631c05))
* add per-channel disable settings and fix CHANNEL_REQUIRED_FOR_ALL bug ([3642462](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3642462670c876052aa668c1515af8c04234cb34))
* add per-section button style and emoji customization via admin API ([a968791](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9687912dfe756e7d772d96cc253f78f2e97185c))
* add Persian (fa) locale with complete translations ([29a3b39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29a3b395b6e67e4ce2437b75120b78c76b69ff4f))
* add POST /auth/telegram/oidc endpoint for OIDC popup flow ([3a400d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a400d9f8b3b4dd2c0bb12fc68f1af6e7c880761))
* add quick purchase email templates to admin panel ([6970340](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6970340e62c67a41f3219759fb0a752617690ea0))
* add RBAC + ABAC permission system for admin cabinet ([3fee54f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3fee54f657dc6e0db1ec36697850ada2235e6968))
* add referral code tracking to all cabinet auth methods + email_templates migration ([18c2477](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/18c24771737994f3ae1f832435ed2247ca625aab))
* add RemnaWave incoming webhooks for real-time subscription events ([6d67cad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d67cad3e7aa07b8490d88b73c38c4aca6b9e315))
* add required channels button to admin settings submenu in bot ([3af07ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3af07ff627fc354da4f8c41b0bd0575dddd9afa5))
* add RESET_TRAFFIC_ON_TARIFF_SWITCH admin setting ([4eaedd3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4eaedd33bf697469fe9ed6a1bfe8b59ca43b46fb))
* add resource_type and request body to audit log entries ([388fc7e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/388fc7ee67f5fc0edf6b7b64b977e12a2d8f0566))
* add separate Freekassa SBP and card payment methods ([0da0c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0da0c5547d0648a70f848fe77c13d583f4868a52))
* add server-complete OAuth linking endpoint for Mini App flow ([f867989](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f867989557d20378cfe815c9c88e1a842c4f6654))
* add startup warnings for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE and MINIAPP_CUSTOM_URL ([476b89f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/476b89fe8e613c505acfc58a9554d31ccf92718a))
* add sub_options support for landing page payment methods ([220196f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/220196fb7abc88b60a37c1fb60786dd3a6ada3ad))
* add Telegram account linking endpoint with security hardening ([da40d56](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da40d5662d6d064090769823d616d6f9748ab5b9))
* add Telegram OIDC id_token validation and code exchange ([2f0a9dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f0a9dc4f3489f7d4311101191129ee95d7edbcc))
* add TELEGRAM_OIDC_* settings for new Telegram Login ([833df51](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/833df518d010d1bfd773eb0c85aaa7e653c7e153))
* add validation to animation config API ([a15403b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a15403b8b6e1ec1bb5c37fdde646e7790373e860))
* add web admin button for admins in cabinet mode ([9ac6da4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ac6da490dffa03ce823009c6b4e5014b7d2bdfb))
* add web campaign links with bonus processing in auth flow ([d955279](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9552799c17a76e2cc2118699528c5b591bd97fb))
* allow editing system roles ([f6b6e22](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f6b6e22a9528dc05b7fbfa80b63051a75c8e73cd))
* allow tariff deletion with active subscriptions ([ebd6bee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebd6bee05ed7d9187de9394c64dfd745bb06b65a))
* attribute campaign registrations to partner for referral earnings ([767e965](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/767e9650285adc72b067b2c0b8a4d1ac5c5bba57))
* blocked user detection during broadcasts, filter blocked from all notifications ([10e231e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10e231e52e0dbabd9195a2df373b3c95129a5e4f))
* capture query params in audit log details for all requests ([bea9da9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bea9da96d44965fcee5e2eba448960443152d4ea))
* colored channel subscription buttons via Bot API 9.4 style ([0b3b2e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b3b2e5dc54d8b6b3ede883d5c0f5b91791b7b9b))
* colored console logs via structlog + rich + FORCE_COLOR ([bf64611](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf646112df02aa7aa7918d0513cb6968ceb7f378))
* configurable Telegram Login Widget with admin settings ([084a3cd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/084a3cd16f8825c389514813ba679748ba235d0a))
* enforce 1-to-1 partner-campaign binding with partner info in campaigns ([366df18](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/366df18c547047a7c69192c768970ebc6ee426fc))
* enhance sales stats with device purchases, per-tariff daily breakdown, and registration tracking ([31c7e2e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31c7e2e9c14cb88762a62a72e4f65051e0c6c1fd))
* expose oidc_enabled and oidc_client_id in telegram-widget config ([000b0c0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/000b0c0592773d0a5f6f572fd8a721ce0f474b2c))
* expose payment sub-options with labels in public landing API ([c53e9af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c53e9af744114e5d6fe014b09b4fac8da1e59c6e))
* expose traffic_reset_mode in subscription response ([59383bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59383bdbd8c72428d151cb24d132452414b14fa3))
* expose traffic_reset_mode in tariff API response ([5d4a94b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5d4a94b8cea8f16f0b4c31e24a4695bee4c67af7))
* guest purchase → cabinet account integration ([f8edfd7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f8edfd77463aad64d9e616569467b4883be4dccf))
* guest purchase delivery & activation system ([776fc3a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/776fc3aadc14e1cc415286cf008fa4eb85f21164))
* handle errors.bandwidth_usage_threshold_reached_max_notifications webhook ([8e85e24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e85e244cb786fb4c06162f2b98d01202e893315))
* handle service.subpage_config_changed webhook event ([43a326a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43a326a98ccc3351de04d9b2d660d3e7e0cb0efc))
* include partner campaigns in /partner/status response ([ea5d932](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ea5d932476553ad1750da3bebbd4b8f055478040))
* link campaign registrations to partner for referral earnings ([c4dc43e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4dc43e054e9faec2f9614fe51a64635f80c1796))
* **localization:** add Persian (fa) locale support and wire it across app flows ([cc54a7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc54a7ad2fb98fe6e662e1923027f4989ae72868))
* notify users on partner/withdrawal approve/reject ([327d4f4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/327d4f4d1559e37dc591adbfd0c839d986d1068d))
* register TELEGRAM_OIDC category, hints in admin settings ([3a36162](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a361628aa543cb629d6967d84d7f474b89c3841))
* rename MAIN_MENU_MODE=text to cabinet with deep-linking to frontend sections ([ad87c5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad87c5fb5e1a4dd0ef7691f12764d3df1530f643))
* replace pip with uv in Dockerfile ([e23d69f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e23d69fcec7ab65a14b054fd46f6ecf87ae6fd13))
* rework guide mode with Remnawave API integration ([5a269b2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a269b249e8e6cad266822095676937481613f5f))
* show all active webhook endpoints in startup log ([9d71005](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d710050ad40ba76a14aa6ace8e8a47f25cdde94))
* unified notification delivery for webhook events (email + WS support) ([26637f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26637f0ae5c7264c0430487d942744fd034e78e8))
* webhook protection — prevent sync/monitoring from overwriting webhook data ([184c52d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/184c52d4ea3ce02d40cf8a5ab42be855c7c7ae23))
* мультиязычные лендинги + гостевые платежи для всех провайдеров ([6deab7d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6deab7dd8c5c5df812bd69608369258a10a67ca4))
* публичные лендинг-страницы для быстрой покупки VPN-подписок ([5e404cc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5e404cc082859d875f988911fcc4eedaa35b886b))
### Bug Fixes
* 3 user deletion bugs — type cast, inner savepoint, lazy load ([af31c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/af31c551d2f23ef01425bdb2db8f255dbc3047e2))
* abs() for transaction amounts in admin notifications and subscription events ([fd139b2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fd139b28a2c45cc3fbd2e01707fb83fbabf57c71))
* add /start burst rate-limit to prevent spam abuse ([61a9722](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61a97220d30031816ab23e33a46717e4895c0758))
* add abs() to expenses query, display flip, contest stats, and recent payments ([de6f806](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de6f80694ba8aa240764e2769ec04c16fe7f3672))
* add action buttons to webhook notifications and fix empty device names ([7091eb9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7091eb9c148aaf913c4699fc86fef5b548002668))
* add activate hint to gift pending activation email link ([fa21549](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa21549cac9098f49e2e32868acce461acd1b40d))
* add blocked_count column migration to universal_migration.py ([b4b10c9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b4b10c998cadbb879540e56dbd0e362b5497ee57))
* add diagnostic logging for device_limit sync to RemnaWave ([97b3f89](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97b3f899d12c4bf32b6229a3b595f1b9ad611096))
* add exc_info traceback to sync user error log ([efdf2a3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efdf2a3189a2f790e570f9a6e19d91469be4ea4f))
* add int32 overflow guards and strengthen auth validation ([50a931e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/50a931ec363d1842126b90098f93c6cae47a9fac))
* add IntegrityError handling on link commit and format fixes ([0c1dc58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c1dc580c67254d11ffb096c22d8c8d78ac18e2b))
* add local traffic_used_gb reset in all tariff switch handlers ([2cdbbc0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cdbbc09ba9a19dcb720049ffde08ba780ac5751))
* add Message-ID and Date headers to outgoing emails ([de541ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de541ea1c3fa20c606c0ea1b69a0223569afb9e2))
* add Message-ID and Date headers to outgoing emails ([e9b4d8e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e9b4d8e444be9ab666caf642c849dcf63b1884ab))
* add migration for partner system tables and columns ([4645be5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4645be53cbb3799aa6b2b6a623af30460357a554))
* add migration for partner system tables and columns ([79ea398](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79ea398d1db436a7812a799bf01b2c1c3b1b73be))
* add min_length to state field, use exc_info for referral warning ([062c486](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/062c4865db194f9d2242772044402fa2711a69bd))
* add missing broadcast_history columns and harden subscription logic ([d4c4a8a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d4c4a8a211eaf836024f8d9dcb725f25f514f05e))
* add missing CHANNEL_CHECK_NOT_SUBSCRIBED localization key ([a47ef67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a47ef67090c4e48f466286f7c676eeee0c61a4fb))
* add missing mark_as_paid_subscription, fix operation order, remove dead code ([5f2d855](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f2d855702dea838b38887a5f44b9ad759acd5cf))
* add missing payment providers to payment_utils and fix {total_amount} formatting ([bdb6161](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bdb61613de378efab4de6de98fde2de3b554c548))
* add missing placeholders to Arabic SUBSCRIPTION_INFO template ([fe54640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe546408857128649930de9473c7cde1f7cc450a))
* add missing subscription columns migration ([b96e819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b96e819da4cc37710e9fc17467045b33bcffac4d))
* add naive datetime guards to fromisoformat() in Redis cache readers ([1b3e6f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b3e6f2f11c20aa240da1beb11dd7dfb20dbe6e8))
* add naive datetime guards to fromisoformat() in Redis cache readers ([6fa4948](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6fa49485d9f1cd678cb5f9fa7d0375fd47643239))
* add naive datetime guards to parsers and fix test datetime literals ([0946090](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/094609005af7358bf5d34d252fc66685bd25751c))
* add passive_deletes to Subscription relationships to prevent NOT NULL violation on cascade delete ([bfd66c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfd66c42c1fba3763f41d641cea1bd101ec8c10c))
* add pending_activation to purchase stats and show total count ([8510597](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8510597ddb501c479d5b70118a94944556ab984f))
* add promo code anti-abuse protections ([97ec39a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97ec39aa803f0e3f03fdcd482df0cbcb86fd1efd))
* add referral_code pattern validation, email login rate limiting, and Retry-After headers ([5499ad6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5499ad62dc98346bef9cb83bf6d8bca319291371))
* add selectinload for campaign registrations in list query ([4d74afd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4d74afd7118524623371f904a93ae1fcbba8d64e))
* add selectinload for subscription in campaign user list ([eb9dba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb9dba3f4728b478f2206ff992700a9677f879c7))
* add startup warning for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE in guide mode ([1d43ae5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d43ae5e25ffcf0e4fe6fec13319d393717e1e50))
* add X-CSRF-Token and X-Telegram-Init-Data to CORS allow_headers ([77456ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/77456efb7504e12c9b9879a352118ce1687132b1))
* address code review findings for Telegram OIDC ([da1cc4f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da1cc4fe5ab6436210185a12dc2a82cb153fc24a))
* address code review issues in guide mode rework ([fae6f71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fae6f71def421e319733e4edcf1ca80a2831b2ec))
* address RBAC review findings (CRITICAL + HIGH) ([1646f04](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1646f04bde47a08f3fd782b7831d40760bd1ba60))
* address remaining abs() issues from review ([ff21b27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff21b27b98bb5a7517e06057eb319c9f3ebb74c7))
* address review findings for guest purchase admin notifications ([770f19e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/770f19e84688e55ed44f7c9de26b0e9ae9636c4b))
* address review findings from agent verification ([cc5be70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc5be7059fdf4cefb01e97196c825b217f8b54b3))
* address review issues in backup, updates, and webhook handlers ([2094886](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20948869902dc570681b05709ac8d51996330a6e))
* address security review findings ([6feec1e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6feec1eaa847644ba3402763a2ffefd8f770cc01))
* align RBAC route prefixes with frontend API paths ([5a7dd3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a7dd3f16408f3497a9765e79a540ccdabc50e69))
* allow email change for unverified emails ([93bb8e0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93bb8e0eb492ca59e29da86594e84e9c486fea65))
* allow non-HTTP deep links in crypto link webhook updates ([f779225](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f77922522a85b3017be44b5fc71da9c95ec16379))
* allow purchase when recalculated price is lower than cached ([19dabf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/19dabf38512ae0c2121108d0b92fc8f384292484))
* allow tariff switch when less than 1 day remains ([67f3547](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67f3547ae2f40153229d71c1abe7e1213466e5c3))
* always include details in successful audit log entries ([3dc0b93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3dc0b93bdfc85fb97f371dc34e024272766afc65))
* AttributeError in withdrawal admin notification (send_to_admins → send_admin_notification) ([c75ec0b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c75ec0b22a3f674d3e1a24b9d546eca1998701b3))
* auth middleware catches all commit errors, not just connection errors ([6409b0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6409b0c023cd7957c43d5c1c3d83e671ccaf959c))
* auto-convert naive datetimes to UTC-aware on model load ([f7d33a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f7d33a7d2b31145a839ee54676816aa657ac90da))
* auto-update permissions for system roles on bootstrap ([eff74be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eff74bed5bcc47a6cfa05c20cad14a40c1572d1f))
* backup restore fails on FK constraints and transaction poisoning ([ff1c872](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff1c8722c9188fdbaf765d6b7e9192686df64850))
* build composite device name from platform + hwid short suffix ([17ce640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17ce64037f198837c8f2aa7bf863871f60bdf547))
* callback routing safety and cache invalidation order ([6a50013](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a50013c21de199df0ba0dab3600b693548b6c1e))
* campaign web link uses ?campaign= param, not ?start= ([28f524b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28f524b7622ed975d2fece66edc94d9713354738))
* cap expected_monthly_referrals to prevent int32 overflow ([2ef6185](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2ef618571570edb6011a365af8aa9cd7e3348c2e))
* centralize balance deduction and fix unchecked return values ([0466528](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0466528925a24087b8522a10cbb11c947c2b7d91))
* centralize has_had_paid_subscription into subtract_user_balance ([e4a6aad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4a6aad621be7ef4e7aedb21373927ede0c8d0a5))
* change CryptoBot URL priority to bot_invoice_url for Telegram opening ([3193ffb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3193ffbd1bee07cb79824d87cb0f77b473b22989))
* classic mode prices overridden by active tariff prices ([628a99e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/628a99e7aa0812842dabc430857190c0cd5c2680))
* clean email verification and password fields from secondary user during merge ([7b4e948](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b4e9488f6fbd1271f063579e48ca9a3c96cb645))
* clean stale squad UUIDs from tariffs during server sync ([fcaa9df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcaa9dfb27350ceda3765c6980ad67f671477caf))
* clear subscription data when user deleted from Remnawave panel ([b0fd38d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b0fd38d60c22247a0086c570665b92c73a060f2f))
* close remaining daily subscription expire paths ([618c936](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/618c936ac9ce4904cd784bf2278d3da188895f2d))
* code style and formatting from review ([a539d69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a539d698546a60aa0a06759f91c77476380a20b1))
* complete datetime.utcnow() → datetime.now(UTC) migration ([eb18994](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb18994b7d34d777ca39d3278d509e41359e2a85))
* complete FK migration — add 27 missing constraints, fix broadcast_history nullable ([fe393d2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe393d2ca6ce302d8213cc751842ea92ef277e76))
* comprehensive security and quality fixes from 7-agent review ([5c55662](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5c55662e2c7068456aeee435b543a851225ff39e))
* comprehensive security hardening from 7-agent review ([e96fe1e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e96fe1ecd8d90878a3fbad9ed76c1a2e7f3a1415))
* connected_squads stores UUIDs, not int IDs — use get_server_ids_by_uuids ([d7039d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7039d75a47fbf67436a9d39f2cd9f65f2646544))
* consume promo offer in miniapp tariff-mode renewal path ([b8857e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b8857e789ef60cf0c8766abbeadd094f62070a61))
* consume promo offer in tariff_purchase.py, fix negative transaction amount ([c8ef808](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c8ef80853915af3e3eb254edd07d8d78b66a9282))
* correct broadcast button deep-links for cabinet mode ([e5fa45f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e5fa45f74f969b84f9f1388f8d4888d22c46d7e8))
* correct cart notification after balance top-up ([2fab50c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2fab50c340c885fc92a4bf797a4b03da6e44af31))
* correct referral withdrawal balance formula and commission transaction type ([83c6db4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/83c6db48349440447305604e944fa440bdceb3fb))
* correct subscription_service import in broadcast cleanup ([6c4e035](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c4e035146934dffb576477cc75f7365b2f27b99))
* count sales from completed payment transactions instead of subscription created_at ([06c3996](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/06c3996da4fa14eafb294651158068c7cda51e52))
* critical OIDC fixes from 7-agent review ([b78c01c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b78c01cae9746275057aaf61c0876ccfd72e1f62))
* critical security and data integrity fixes for partner system ([8899749](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/88997492c3534ea2f6e194c0382c77302557c2f3))
* cross-validate Telegram identity on every authenticated request ([973b3d3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/973b3d3d3ff80376c0fd19c531d7aac3ae751df8))
* CryptoBot guest payment — remove is_paid [@property](https://github.com/property) write, use correct status ([6f871ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6f871edc9d01ca20d1b194a157d3d6ae46512d05))
* daily tariff subscriptions stuck in expired/disabled with no resume path ([80914c1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/80914c1af739aa0ee1ea75b0e5871bf391b9020d))
* deadlock on user deletion + robust migration 0002 ([b7b83ab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b7b83abb723913b3167e7462ff592a374c3f421b))
* delete cross-referral earnings before bulk reassignment, clear secondary.referred_by_id ([f204b67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f204b678803297ce60faad628d16f46344b11ed0))
* delete subscription_servers before subscription to prevent FK violation ([7d9ced8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d9ced8f4f71b43ed4ac798e6ff904a086e1ac4a))
* device_limit fallback 1→0 для корректного отображения безлимита ([3e26832](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3e26832e745368a0dab2617e4e8ae2c410c6bca2))
* don't delete Heleket invoice message on status check ([9943253](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/994325360ca7665800177bfad8f831154f4d733f))
* downgrade Telegram timeout errors to warning in monitoring service ([e43a8d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e43a8d6ce4c40a7212bf90644f82da109717bdcb))
* downgrade transient API errors (502/503/504) to warning level ([ec8eaf5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ec8eaf52bfdc2bde612e4fc0324575ba7dc6b2e1))
* eliminate deadlock by matching lock order with webhook ([d651a6c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d651a6c02f501b7a0ded570f2db6addcc16173a9))
* eliminate double panel API call on tariff change, harden cart notification ([b2cf4aa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2cf4aaa91f3fb63dca7e70645cadb75aa158cfe))
* eliminate referral system inconsistencies ([60c97f7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/60c97f778bc4cc18aaf4d8a31826bc831c3b3f8f))
* email verification bypass, ban-notifications size limit, referral balance API ([256cbfc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/256cbfcadfd2fc88d8de69557c78618639af157d))
* empty JSONB values exported as None in backup ([57aaca8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57aaca82f5bf9d7bdd9d4b924aa3412d85eccbb5))
* enforce user restrictions in cabinet API and fix poll history crash ([faba3a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/faba3a8ed6d428305f9ca7d7fd9bdcc1fd72ba52))
* expand backup coverage to all 68 models and harden restore ([02e40bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e40bd6f7ef8e653cae53ccd127f2f79009e0d4))
* extend naive datetime guard to all model properties ([bd11801](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd11801467e917d76005d1a782c71f5ae4ffee6e))
* extract device name from nested hwidUserDevice object ([79793c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79793c47bbbdae8b0f285448d5f70e90c9d4f4b0))
* extract real client IP from X-Forwarded-For/X-Real-IP headers ([af6686c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/af6686ccfae12876e867cdabe729d0c893bd85a1))
* filter out traffic packages with zero price from purchase options ([64a684c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64a684cd2ff51e663a1f70e61c07ca6b4f6bfc91))
* flood control handling in pinned messages and XSS hardening in HTML sanitizer ([454b831](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454b83138e4db8dc4f07171ee6fe262d2cd6d311))
* force basicConfig to replace pre-existing handlers ([7eb8d4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7eb8d4e153bab640a5829f75bfa6f70df5763284))
* freekassa OP-SP-7 error and missing telegram notification ([200f91e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/200f91ef1748bb6213d1ef3a8e83ae976290a8a7))
* from redis.exceptions import NoScriptError ([667291a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/667291a2dcaeae21e27eeb6376085e69caa4e45a))
* generate missing crypto link on the fly and skip unresolved templates ([4c72058](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c72058d4ad8b0594991b17323928d9004803bfa))
* grant legacy config-based admins full RBAC access ([8893fc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8893fc128e3d8927054f1df1647e896e780c69e7))
* handle duplicate remnawave_uuid on email sync ([eaeee7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaeee7a765c03ff33e2928cdb41be91948eca95c))
* handle expired callback queries and harden middleware error handling ([f52e6ae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f52e6aedac3de1c9bb2ad1a5a16b06d38b79ab63))
* handle expired ORM attributes in sync UUID mutation ([9ae5d7b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ae5d7bb60c57e2c29d6f3c5098c23450d5feb61))
* handle naive datetime in raw SQL row comparison (payment/common) ([38f3a9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38f3a9a16a24e85adf473f2150aad31574a87060))
* handle naive datetimes in Subscription properties ([e512e5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e512e5fe6e9009992b5bc8b9be7f53e0612f234a))
* handle NULL used_promocodes for migrated users ([cdcabee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cdcabee80d1d7f0b367a97cdec20bb49e8592115))
* handle nullable traffic_limit_gb and end_date in subscription model ([e94b93d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e94b93d0c10b4e61d7750ca47e1b2f888f5873ed))
* handle photo message in ticket creation flow ([e182280](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e1822800aba3ea5eee721846b1e0d8df0a9398d1))
* handle RemnaWave API errors in traffic aggregation ([ed4624c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed4624c6649bdbc04bc850ef63e5c86e26a37ce4))
* handle StaleDataError in webhook user.deleted server counter decrement ([c30c2fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30c2feee1db03f0a359b291117da88002dd0fe0))
* handle StaleDataError in webhook when user already deleted ([d58a80f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d58a80f3eaa64a6fc899e10b3b14584fb7fc18a9))
* handle tariff_extend callback without period (back button crash) ([ba0a5e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba0a5e9abd9bd582968d69a5c6e57f336094c782))
* handle TelegramBadRequest in ticket edit_message_text calls ([8e61fe4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e61fe47746da2ac09c3ea8c4dbfc6be198e49e3))
* handle time/date types in backup JSON serialization ([27365b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27365b3c7518c09229afcd928f505d0f3f66213f))
* handle unique constraint conflicts during backup restore without clear_existing ([5893874](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/589387477624691e0026086800428e7e52e06128))
* handle YooKassa NotFoundError gracefully in get_payment_info ([df5b1a0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5b1a072d99ff8aee0c94304b2a0214f0fcffe7))
* harden account merge security and correctness ([d855e9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d855e9e47fab1a038e581437a9921bdfeb11e927))
* harden backup create/restore against serialization and constraint errors ([fc42916](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fc42916b10bb698895eb75c0e2568747647555d3))
* hide traffic topup button when tariff doesn't support it ([399ca86](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/399ca86561f4271e9c542bac87c0dd2931a223e0))
* HTML parse fallback, email change race condition, username length limit ([d05ff67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d05ff678abfacaa7e55ad3e55f226d706d32a7b7))
* HTML-escape all externally-sourced text in guide messages ([711ec34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/711ec344c646844401f355695a7e8c0d4fb401ee))
* ignore 'message is not modified' on privacy policy decline ([be1da97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be1da976e14a35e6cca01a7fca7529c55c1a208b))
* improve campaign notifications and ticket media in admin topics ([a594a0f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a594a0f79f48227f75d6102b4586179102c4d344))
* improve campaign routes, schemas, and add database indexes ([ded5c89](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ded5c899f7425707b17fef4d0d5ceafac777ef08))
* improve deduplication log message wording in monitoring service ([2aead9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2aead9a68b6bf274c8d1497c85f2ed4d4fc9c70b))
* include desired_commission_percent in admin notification ([dc3d22f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc3d22f52db40150d595bccf524d38790e5725d9))
* initialize logger in bot_configuration.py ([988d0e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/988d0e5c2f27538135d757187a0b6770f078b1d9))
* invalidate app config cache on local file saves ([978726a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/978726a7856cf56257c49491afe569fa8c395eac))
* limit Rich traceback output to prevent console flood ([11ef714](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11ef714e0dde25a08711c0daeee943b6e71e20b7))
* make migration 0002 robust with table existence checks ([f076269](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f076269c323726c683a38db092d907591a26e647))
* make migrations 0010/0011 idempotent, escape HTML in crash notification ([a696896](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a696896d2c4a3d0d6026398fcdc76ded9575375d))
* make users.promo_group_id nullable — sync DB with model ([e0f2243](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e0f2243f49ca8cc741a5c07b63ef3eb2abdef52c))
* medium-priority fixes for partner system ([7c20fde](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c20fde4e887749d72280a8804467645e5bab416))
* **merge:** validate before consuming token, add flush, defensive balance ([bc1e6fb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc1e6fb22c6e23c7a34364796f51a55c60224aff))
* migrate all remaining naive timestamp columns to timestamptz ([708bb9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/708bb9eec7ea4360b26709fb2a3f82dd139ed600))
* migrate VK OAuth to VK ID OAuth 2.1 with PKCE ([1dfa780](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1dfa78013c4fb926a2b32bf4d63baa28215e7340))
* MissingGreenlet on campaign registrations access ([018f18f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/018f18fa0c9bba1a1dbca8b2398b9611d0c94c36))
* move PartnerStatus enum before User class to fix NameError ([acc1323](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acc1323a542b8e92433cabf1334d2d98bfa21e21))
* NameError in set_user_devices_button — undefined action_text ([1b8ef69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b8ef69a1bbb7d8d86827cf7aaa4f05cbf480d75))
* negative balance transfer, linking state validation, referrer migration ([531d5cf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/531d5cff3019e72dde6ee64977cb801e8f8c8d0b))
* normalize transaction amount signs across all aggregations ([4247981](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4247981c98111af388c98628c1e61f0517c57417))
* nullify payment FK references before deleting transactions in user restoration ([0b86f37](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b86f379b4e55e499ca3d189137e2aed865774b5))
* partner system — CRUD nullable fields, per-campaign stats, atomic unassign, diagnostic logging ([ed3ae14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed3ae14d0c378fa0dc2d442c3aa5a70172f3132c))
* pass return_url to all payment providers for guest purchases ([b85646a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b85646af85c4b2036f1c07c89e3e282f74d43c1e))
* payment race conditions, balance atomicity, renewal rollback safety ([c5124b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c5124b97b63eda59b52d2cbf9e2dcdaa6141ed6e))
* photo handling in QR messages ([1afcd84](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1afcd84e0ed2c39abd674170b8b17e6c7ee8754d))
* pre-existing bugs found during review ([1bb939f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bb939f63a360a687fafba26bc363024df0f6be0))
* pre-validate CABINET_BUTTON_STYLE to prevent invalid values from suppressing per-section defaults ([46c1a69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46c1a69456036cb1be784b8d952f27110e9124eb))
* preserve connected_squads during subscription replacement cleanup ([d86c29a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d86c29a5d384db1d11ef3666153fa288d0c822d8))
* preserve payment initiation time in transaction created_at ([90d9df8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90d9df8f0e949913f09c4ebed8fe5280453ab3ab))
* preserve purchased traffic when extending same tariff ([b167ed3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b167ed3dd1c6e6239db2bdbb8424bcb1fb7715d9))
* prevent 'caption is too long' error in logo mode ([6e28a1a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6e28a1a22b02055b357051dfecbee7fefbebc774))
* prevent cascading greenlet errors after sync rollback ([a1ffd5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1ffd5bda6b63145104ce750835d8e6492d781dc))
* prevent concurrent device purchases exceeding max device limit ([1cfede2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cfede28b7570bcaf77cb53d6b2a9f3b0e4e9408))
* prevent daily subscriptions from being expired by middleware/CRUD/webhook ([0ed6397](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ed6397fa9e5810fcffc9152ab2241fcf37cf85a))
* prevent fileConfig from destroying structlog handlers ([e78b104](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e78b1040a50ac14759bceab396d0c3e34dd79cdd))
* prevent infinite reuse of first_purchase_only promo code discounts ([2cec8dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cec8dc4a487017f4b1c5ca80710f2d70045b825))
* prevent negative amounts in spent display and balance history ([c30972f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30972f6a7911a89a6c3f2080019ff465d11b597))
* prevent partner self-referral via own campaign link ([115c0c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/115c0c84c0698591da75d7d3b8fbd8e0fc8541ea))
* prevent race condition expiring active daily subscriptions ([bfef7cc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfef7cc6296e296f17068e519469c3deaddc1b3b))
* prevent self-referral loops, invalidate all sessions on merge ([db61365](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db61365e11ccec4dd45671b33da00f4b05484589))
* prevent squad drop on admin subscription type change, require subscription for wheel spins ([59f0e42](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59f0e42be7e3c679d15cf2fc6820ab7097cd2201))
* prevent sync from overwriting end_date for non-ACTIVE panel users ([49871f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49871f82f37d84979ea9ec91055e3f046d5854be))
* prevent sync from overwriting subscription URLs with empty strings ([9c00479](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9c004791f28fbcf314b93c1b2a38593069605239))
* promo code max_uses=0 conversion and trial UX after promo activation ([1cae713](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cae7130bc87493ab8c7691b3c22ead8189dab55))
* protect active paid subscriptions from being disabled in RemnaWave ([1b6bbc7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b6bbc7131341b4afd739e4195f02aa956ead616))
* protect server counter callers and fix tariff change detection ([bee4aa4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bee4aa42842b8b6611c7c268bcfced408a227bc0))
* RBAC API response format fixes and audit log user info ([4598c27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4598c2785a42773ee8be04ada1c00d14824e07e0))
* RBAC audit log action filter and legacy admin level ([c1da8a4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c1da8a4dba5d0c993d3e15b2866bdcfa09de1752))
* read discount overrides from landing model instead of response DTO ([6d65e15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d65e152669a7e92f93f592993c1d5507b890046))
* read OIDC enabled setting from DB in auth endpoint ([2405dc5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2405dc5c1b6d6266da373e0e4dac6444b0e70a03))
* reassign orphaned records on merge, eliminate TOCTOU race ([d7a9d2b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7a9d2bfba5b796882d3e04be6038b766cd0a4c8))
* redis cache uses sync client due to import shadowing ([667291a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/667291a2dcaeae21e27eeb6376085e69caa4e45a))
* reject promo codes for days when user has no subscription or trial ([e32e2f7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e32e2f779d014d587b58d63b513fd913ae1b7a41))
* remove [@username](https://github.com/username) channel ID input, auto-prefix -100 for bare digits ([a7db469](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7db469fd7603e7d8dac3076f5d633da654a3a57))
* remove decorative cloudpayments sub-options ([694aecc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/694aeccc3121116bf193b5766572de7472eb4016))
* remove DisplayNameRestrictionMiddleware ([640da34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/640da3473662cfdcceaa4346729467600ac3b14f))
* remove executable bit from email_service.py ([372d628](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/372d628908294d905c37828219cac6aef7941151))
* remove gemini-effect and noise from allowed background types ([731eb24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/731eb2436428d0e12f1e5ccdebc72cd74fd7c65e))
* remove local UTC re-imports shadowing module-level import in purchase.py ([e68760c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e68760cc668016209f4f19a2e08af8680343d6ed))
* remove premature tariff_id assignment in _apply_extension_updates ([b47678c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b47678cfb0ba5897b37dfe1f94e3d1336af5698e))
* remove redundant trial inactivity monitoring checks ([d712ab8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d712ab830166cab61ce38dd32498a8a9e3e602b0))
* remove subscription connection links from guest purchase emails ([9217352](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9217352685189118620f1246bc7d7a4459883ed6))
* remove unused PaymentService from MonitoringService init ([491a7e1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/491a7e1c425a355e55b3020e2bcc7b96047bdf5e))
* renewals stats empty on all-time filter ([e25fcfc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e25fcfc6ef941465b83f368f152304ea5a6747d9))
* reorder button_click_logs migration to nullify before ALTER TYPE ([df5415f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5415f30b2aae4412ff5fbd3cac8076128b818c))
* repair missing DB columns and make backup resilient to schema mismatches ([c20355b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c20355b06df13328f85cc5a6045b3e490419a30a))
* replace deprecated Query(regex=) with pattern= ([871ceb8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/871ceb866ccf1f3a770c7ef33406e1a43d0a7ff7))
* reset QR photo when returning to referral ([3ee108f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3ee108fce85962dde5bc6c80b3464278369da9f5))
* reset traffic purchases on expired subscription renewal + pricing fixes ([dce9eaa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dce9eaa5971cb1dc0945747e02397a250e8e411b))
* resolve deadlock on server_squads counter updates and add webhook notification toggles ([57dc1ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57dc1ff47f2f6183351db7594544a07ca6f27250))
* resolve exc_info for admin notifications, clean log formatting ([11f8af0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11f8af003fc60384abafa2b670b89d6ad3ac57a4))
* resolve GROUP BY mismatch for daily_by_tariff query ([e5f29eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e5f29eb041e88bc6315f0b4da3b78898d9dd7fff))
* resolve HIGH-priority performance and security issues in partner system ([fcf3a2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcf3a2c8062752b2b1dc06b5993ac2d8ae80ee85))
* resolve MissingGreenlet error when accessing subscription.tariff ([a93a32f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a93a32f3a7d1b259a2e24954ae5d2b7c966c5639))
* resolve ruff lint errors (import sorting, unused variable) ([b2d7abf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2d7abf5bd10a98fd7ad1da50b5072afc65a5b48))
* resolve sync 404 errors, user deletion FK constraint, and device limit not sent to RemnaWave ([1ce9174](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ce91749aa12ffcefcf66bea714cea218739f3fe))
* restore merge token on DB failure, fix partner_status priority ([9582758](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9582758d1c85735c8ead8cbfeb56bbdae45288af))
* restore panel user discovery on admin tariff change, localize cart reminder ([1256ddc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1256ddcd1a772f90e7bdf9437043a47ea9d84d53))
* restore RemnaWave config management endpoints ([6f473de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6f473defef32a6d81cee55ef2cd397d536a784a7))
* restore subscription_url and crypto_link after panel sync ([26efb15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26efb157e476a18b036d09167628a295d7e4c10b))
* return zeroed stats dict when withdrawal is disabled ([7883efc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7883efc3d6e6d8bedf8e4b7d72634cbab6e2f3d7))
* review findings — exception chaining, redundant unquote, validator tightening ([467dea1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/467dea1315fbaf8d09ccbba292cd0bcc60d9f3ab))
* safe HTML preview truncation and lazy-load subscription fallback ([40d8a6d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/40d8a6dc8baf3f0f7c30b0883898b4655a907eb5))
* second round review fixes for account merge ([64ee045](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64ee0459e4e3d3fe87ad65387fcbcb147147ac1b))
* security and architecture fixes for webhook handlers ([dc1e96b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc1e96bbe9b4496e91e9dea591c7fc0ef4cc245b))
* separate base and purchased traffic in renewal pricing ([739ba29](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/739ba2986f41b04058eb14e8b87b0699fe96f922))
* show negative amounts for withdrawals in admin transaction list ([5ee45f9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ee45f97d179ce2d32b3f19eeb6fd01989a30ca7))
* skip blocked users in trial notifications and broadcasts without DB status change ([493f315](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/493f315a65610826a04e04c3d2065e0b395426ed))
* skip users with active subscriptions in admin inactive cleanup ([e79f598](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e79f598d17ffa76372e6f88d2a498accf8175c76))
* specify foreign_keys on User.admin_roles_rel to resolve ambiguous join ([bc7d061](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc7d0612f1476f2fdb498cd76a9374b41fd9440a))
* stack promo group + promo offer discounts in bot (matching cabinet) ([628997f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/628997fb48413cc4fae9ac491d1c7f6185877200))
* stop CryptoBot webhook retry loop and save cabinet payments to DB ([2cb6d73](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cb6d731e96cbfc305b098d8424b84bfd6826fb4))
* suppress 'message is not modified' error in updates panel ([3a680b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a680b41b0124848572809d187cab720e1db8506))
* suppress bot-blocked-by-user error in AuthMiddleware ([fda9f3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fda9f3beecbfcca4d7abc16cf661d5ad5e3b5141))
* suppress expired callback query error in AuthMiddleware ([2de4384](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2de438426a647e2bcae9b4d99eef4093ff8b5429))
* suppress startup log noise (~350 lines → ~30) ([8a6650e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a6650e57cd8ea396d9b057a7753469947f38d29))
* suppress web page preview when logo mode is disabled ([1f4430f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f4430f3af8f3efcc58ef7b562904adcb1640a44))
* sync subscription status from panel in user.modified webhook ([5156d63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5156d635f0b5bc0493e8f18ce9710cca6ff4ffc8))
* sync support mode from cabinet admin to SupportSettingsService ([516be6e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/516be6e600a08ad700d83b793dc64b2ca07bdf44))
* sync SUPPORT_SYSTEM_MODE between SystemSettings and SupportSettings ([0807a9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0807a9ff19d1eb4f1204f7cbeb1da1c1cfefe83a))
* sync traffic reset across all tariff switch code paths ([d708365](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d708365aca9dfd5c3afda1a1de4303e0bd1d263e))
* sync uv.lock version with pyproject.toml 3.23.1 ([8eb6a8c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8eb6a8c4606a0ea48e383c031ad83219fc8e062b))
* sync uv.lock version with pyproject.toml 3.23.1 ([bc52fd2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc52fd27113f95a4154b1990142d46ae606fd2e0))
* ticket creation crash and webhook PendingRollbackError ([760c833](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/760c833b7402541d3c7cf2ed7fc0418119e75042))
* traceback in Telegram notifications + reduce log padding ([909a403](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/909a4039c43b910761bd05c36e79c8e6773199db))
* transaction boundary and CORS in webapi ([6495384](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6495384bcfd76c377971438f6c132f1404ea1f7d))
* translate required channels handler to Russian, add localization keys ([1bc9074](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bc9074c1bcdaba7215065c77aac9dd51db4d7c8))
* treat empty icon_url as None in payment method validation ([ab981dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ab981dce0d84bba3df5fc4366e39ba3ed0adeccd))
* unassign all campaigns when revoking partner status ([d39063b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d39063b22ffb6442e275db39704361cdb9251793))
* UnboundLocalError for get_logo_media in required_sub_channel_check ([d3c14ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c14ac30363839d1340129f279a7a7b4b021ed1))
* UniqueViolation при мерже аккаунтов с общим OAuth/telegram/email ID ([1c89bd8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1c89bd8b2acfe49de2c97dd75446a037a54fded7))
* uploaded backup restore button not triggering handler ([ebe5083](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebe508302b906f8b56cb230b934fb8566990c684))
* use .is_(True) and add or 0 guards per code review ([69b5ca0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69b5ca06701e7381c39448e2bf6b927f0558058c))
* use actual DB columns for subscription fallback query ([f0e7f8e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f0e7f8e3bec27d97a3f22445948b8dde37a92438))
* use aiogram 3.x bot.download() instead of document.download() ([205c8d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/205c8d987d93151a17aa0793cb51bd99917aea97))
* use AwareDateTime TypeDecorator for all datetime columns ([a7f3d65](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7f3d652c51ecd653900a530b7d38feaf603ecf1))
* use callback fallback when MINIAPP_CUSTOM_URL is not set ([eaf3a07](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaf3a07579729031030308d77f61a5227b796c02))
* use direct is_trial access, add missing error codes to promo APIs ([69a9899](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69a9899d40dda83e83cbdba1aa43d9d1f756704b))
* use event field directly as event_name (already includes scope prefix) ([9aa22af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9aa22af3390a249d1b500d75a7d7189daaed265e))
* use float instead of int | float (PYI041) ([310edae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/310edae013973d8533051088f3720cc5da3651b5))
* use flush instead of commit in server counter functions ([6cec024](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6cec024e46ef9177cb59aa81590953c9a75d81bb))
* use get_rendered_override for proper variable substitution in guest email overrides ([c165cca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c165cca3239c9a1249aae9e5e712f7e34fb01107))
* use SAVEPOINT instead of full rollback in sync user creation ([2a90f87](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2a90f871b97b2b7ee8289e62294c65f8becb2539))
* use selection.period.days instead of selection.period_days ([4541016](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/45410168afe683675003a1c41c17074a54ce04f1))
* use short TTL fallback in restore_merge_token on parse error ([0e8c61a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0e8c61a7762ae796284144056c0cbdbcb53b6c7c))
* use sync context manager for structlog bound_contextvars ([25e8c9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25e8c9f8fc4d2c66d5a1407d3de5c7402dc596da))
* use traffic topup config and add WATA 429 retry ([b5998ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5998ea9d22644ed2914b0e829b3a76a32a69ddf))
* validate payment sub-option suffix and harden payment method handling ([5f01783](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f01783dcb63f2f8bc20fef935d74d7588273aea))
* webhook notification 'My Subscription' button uses unregistered callback_data ([1e2a7e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e2a7e3096af11540184d60885b8c08d73506c4a))
* webhook:close button not working due to channel check timeout ([019fbc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/019fbc12b6cf61d374bbed4bce3823afc60445c9))
* wrap user deletion steps in savepoints to prevent transaction cascade abort ([a38dfcb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a38dfcb75a47a185d979a8202f637d8b79812e67))
* безопасность и качество кода лендингов — 16 исправлений ([ef45095](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ef450955e6b37d437dabac55da037f53ca1f75dc))
* гарантировать положительный доход от подписок и исправить общий доход ([93a55df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93a55df4c0ac099946d440ec79fefb24327ab0e1))
* дедупликация promocode_uses при мерже аккаунтов ([00a7db2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/00a7db26905d53a9a978aaf6b97800ca3042b957))
* добавить create_transaction для 6 потоков оплаты с баланса ([374907b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/374907b6078c483531061465983e23f281e841a2))
* добавить create_transaction и admin-уведомления для автопродлений ([9f35088](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9f35088788c971cb757936dba7214abe54477af0))
* добавить ON DELETE CASCADE/SET NULL на все FK к users.id ([34c82c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/34c82c348829cf528154bd1e2f5d77006d7ed5da))
* добавить пробелы в формат тарифов (1000 ГБ / 2 📱) ([900be65](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/900be65617dd5bbc6ffdcc82bb5504e1a93ead95))
* дубликаты системных ролей при переименовании и сброс permissions ([7a7fb71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7a7fb71bf535e2a501f0677747ba63ca0b27ede5))
* изолировать stored_amount от downstream consumers в create_transaction ([b87535a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b87535ad4842cbf1f99f6fc1e28b5932fa5e3baa))
* исправления системы реферальных конкурсов ([6713b34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6713b3497854e73dddc212280d7bf12db818f38a))
* кнопка «Назад» в тарифах ведёт в админ панель, а не в настройки ([04562fd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04562fd7e74de26776517549730819389b24a0d0))
* миграция 0016 падает если FK constraint отсутствует в БД ([15fe45d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/15fe45d11341001714599f8db963d182dc371aa3))
* миграция 0021 — drop server_default перед сменой типа на JSON ([3d3bb3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3d3bb3badb55511960ed9b2a29ea67e0f0c3f26c))
* передать явный диапазон дат для all_time_stats в дашборде ([968d147](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/968d14704610eed528bca28cbf295c1ba1644a5a))
* показывать кнопку покупки тарифа вместо ошибки для триальных подписок ([acfa4b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acfa4b3c2ea96e74d93470085265df76ec50e1e6))
* показывать только активные провайдеры на странице /profile/accounts ([9d7a557](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d7a557ef0e294ce9920e9953bb1358656ff9b81))
* промокоды — конвертация триалов, race condition, savepoints ([7fb839a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7fb839aef6234294b95064f9575c19d5a0c3f892))
* реактивация DISABLED подписок при покупке трафика для LIMITED пользователей ([7d28f55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d28f5516a52606280219cbea846fba431da80d2))
* реактивация DISABLED подписок при покупке устройств и в REST API ([b9e17be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b9e17be8554a65eaf765a0b5b36fee062205c66f))
* синхронизация версии pyproject.toml с main и обновление uv в Dockerfile ([b31a893](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b31a893b13b2db911e51298ceb0107419f9a4cb3))
* убрать WITHDRAWAL из автонегации, добавить abs() в агрегации, исправить all_time_stats ([6da61d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6da61d79510f7e05310f3cc020515b4dd0b3eb34))
* убрать избыточный минус в amount_kopeks для create_transaction ([849b3a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/849b3a7034f2291db40e049c12e1b7c71b58bab1))
* устранение race condition при покупке устройств через re-lock после коммита ([a7a18dd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7a18dd0d1d59c64f7e4dd3ddc1b8cec47198077))
* устранение race conditions и атомарность платёжной системы ([4984f20](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4984f20e8fb030ee338723d797d51aee21f67ca8))
* устранение каскадного PendingRollbackError при восстановлении бэкапа ([8259278](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/82592784d0da8b8718f3b3aa34076af59ad2a878))
### Performance
* cache logo file_id to avoid re-uploading on every message ([142ff14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/142ff14a502e629446be7d67fab880d12bee149d))
### Refactoring
* complete structlog migration with contextvars, kwargs, and logging hardening ([1f0fef1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f0fef114bd979b2b0d2bd38dde6ce05e7bba07b))
* extract shared OAuth linking logic, add Literal types for providers ([f7caf0d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f7caf0de709ca6a46283f0b1928e34f8908f2c93))
* improve log formatting — logger name prefix and table alignment ([f637204](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f63720467a935bdaaa58bb34d588d65e46698f26))
* remove "both" mode from BOT_RUN_MODE, keep only polling and webhook ([efa3a5d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efa3a5d4579f24dabeeba01a4f2e981144dd6022))
* remove Flask, use FastAPI exclusively for all webhooks ([119f463](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/119f463c36a95685c3bc6cdf704e746b0ba20d56))
* remove legacy app-config.json system ([295d2e8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/295d2e877e43f48e9319ba0b01be959904637000))
* remove modem functionality from classic subscriptions ([ee2e79d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ee2e79db3114fe7a9852d2cd33c4b4fbbde311ea))
* remove smart auto-activation & activation prompt, fix production bugs ([a3903a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a3903a252efdd0db4b42ca3fd6771f1627050a7f))
* replace universal_migration.py with Alembic ([b6c7f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6c7f91a7c79d108820c9f89c9070fde4843316c))
* replace universal_migration.py with Alembic ([784616b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/784616b349ef12b35ee021dd7a7b2a2ef9fc57f6))
## [3.23.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.23.1...v3.23.2) (2026-03-06)
### Bug Fixes
* device_limit fallback 1→0 для корректного отображения безлимита ([3e26832](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3e26832e745368a0dab2617e4e8ae2c410c6bca2))
* sync uv.lock version with pyproject.toml 3.23.1 ([8eb6a8c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8eb6a8c4606a0ea48e383c031ad83219fc8e062b))
* sync uv.lock version with pyproject.toml 3.23.1 ([bc52fd2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc52fd27113f95a4154b1990142d46ae606fd2e0))
* миграция 0016 падает если FK constraint отсутствует в БД ([15fe45d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/15fe45d11341001714599f8db963d182dc371aa3))
## [3.23.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.23.0...v3.23.1) (2026-03-06)
+19 -32
View File
@@ -197,28 +197,17 @@ async def create_subscription(
### Документация кода
```python
async def calculate_subscription_price(
period_days: int,
traffic_gb: int,
devices_count: int,
servers_count: int
) -> int:
"""
Рассчитывает стоимость подписки.
Args:
period_days: Период подписки в днях
traffic_gb: Лимит трафика в ГБ (0 = безлимит)
devices_count: Количество устройств
servers_count: Количество серверов
Returns:
Стоимость в копейках
Raises:
ValueError: Если переданы некорректные параметры
"""
# implementation
from app.services.pricing_engine import PricingEngine
pricing = PricingEngine.calculate_renewal_price(
subscription=subscription,
period_days=30,
user=user,
)
# pricing.final_total — стоимость в копейках
# pricing.original_total — цена до скидок
# pricing.promo_group_discount — скидка промогруппы
# pricing.promo_offer_discount — скидка промо-оффера
```
### Обработка ошибок
@@ -341,20 +330,18 @@ python main.py
### Тестирование компонентов
```python
# tests/test_subscription_service.py
# tests/services/test_pricing_engine.py
import pytest
from app.services.subscription_service import SubscriptionService
from app.services.pricing_engine import PricingEngine
@pytest.mark.asyncio
async def test_calculate_price():
price = await SubscriptionService.calculate_subscription_price(
def test_calculate_renewal_price():
pricing = PricingEngine.calculate_renewal_price(
subscription=mock_subscription,
period_days=30,
traffic_gb=100,
devices_count=3,
servers_count=1
user=mock_user,
)
assert price > 0
assert isinstance(price, int)
assert pricing.final_total > 0
assert isinstance(pricing.final_total, int)
```
### Integration тесты
+2 -2
View File
@@ -15,11 +15,11 @@ WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=uv.lock,target=uv.lock \
uv sync --locked --no-dev
uv sync --frozen --no-dev
FROM python:3.13-slim
ARG VERSION="v3.23.1" # x-release-please-version
ARG VERSION="v3.32.1" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+23
View File
@@ -610,6 +610,16 @@ hooks.domain.com {
}
}
handle /riopay-webhook {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
handle /remnawave-webhook {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
@@ -827,6 +837,18 @@ http {
proxy_buffering off;
proxy_request_buffering off;
}
location = /riopay-webhook {
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_request_buffering off;
}
location = /remnawave-webhook {
proxy_pass http://remnawave_bot_unified;
@@ -1455,6 +1477,7 @@ CONTEST_BUTTON_VISIBLE=true
- 💳 **WATA**
- 💳 **Freekassa** (NSPK СБП + карты)
- 💳 **CloudPayments** (карты + СБП)
- 💳 **RioPay** (карты + СБП)
- 🔥 Автогенерация счетов и webhook-уведомления
- 💼 История операций
- 🔄 Автоплатёж с настройкой дня списания
+10 -1
View File
@@ -60,6 +60,7 @@ from app.handlers.admin import (
welcome_text as admin_welcome_text,
)
from app.handlers.channel_member import register_handlers as register_channel_member_handlers
from app.handlers.gift_activation import register_handlers as register_gift_activation_handlers
from app.handlers.stars_payments import register_stars_handlers
from app.middlewares.auth import AuthMiddleware
from app.middlewares.blacklist import BlacklistMiddleware
@@ -199,6 +200,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_blocked_users.register_handlers(dp)
admin_required_channels.register_handlers(dp)
register_channel_member_handlers(dp)
register_gift_activation_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_contests.register_handlers(dp)
@@ -246,7 +248,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
elif settings.is_cabinet_mode():
logger.info('🏠 Режим Cabinet активен, базовый URL', MINIAPP_CUSTOM_URL=settings.MINIAPP_CUSTOM_URL)
# Load per-section button styles cache
# Load per-section button styles cache and menu layout cache
if settings.is_cabinet_mode():
try:
from app.utils.button_styles_cache import load_button_styles_cache
@@ -255,6 +257,13 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
except Exception as e:
logger.warning('Failed to load button styles cache', error=e)
try:
from app.utils.menu_layout_cache import load_menu_layout_cache
await load_menu_layout_cache()
except Exception as e:
logger.warning('Failed to load menu layout cache', error=e)
logger.info('Бот успешно настроен')
return bot, dp
+4 -1
View File
@@ -2,21 +2,24 @@
from .jwt_handler import (
create_access_token,
create_auto_login_token,
create_refresh_token,
decode_token,
get_token_payload,
)
from .password_utils import hash_password, verify_password
from .telegram_auth import validate_telegram_init_data, validate_telegram_login_widget
from .telegram_auth import validate_telegram_init_data, validate_telegram_login_widget, validate_telegram_oidc_token
__all__ = [
'create_access_token',
'create_auto_login_token',
'create_refresh_token',
'decode_token',
'get_token_payload',
'hash_password',
'validate_telegram_init_data',
'validate_telegram_login_widget',
'validate_telegram_oidc_token',
'verify_password',
]
+12
View File
@@ -123,6 +123,18 @@ def get_token_payload(token: str, expected_type: str = 'access') -> dict[str, An
return payload
def create_auto_login_token(user_id: int, ttl_hours: int = 72) -> str:
"""Short-lived JWT for auto-login from guest purchase success page."""
expires = datetime.now(UTC) + timedelta(hours=ttl_hours)
payload = {
'sub': str(user_id),
'type': 'auto_login',
'exp': expires,
'iat': datetime.now(UTC),
}
return jwt.encode(payload, settings.get_cabinet_jwt_secret(), algorithm=JWT_ALGORITHM)
def get_refresh_token_expires_at() -> datetime:
"""Get the expiration datetime for a new refresh token."""
expire_days = settings.get_cabinet_refresh_token_expire_days()
+123 -1
View File
@@ -1,15 +1,23 @@
"""Telegram authentication validation for cabinet."""
import asyncio
import hashlib
import hmac
import json
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from typing import Any
from urllib.parse import parse_qsl
import httpx
import jwt as pyjwt
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
# Maximum allowed clock skew (seconds) for auth_date — tolerates minor drift between Telegram servers and ours.
_MAX_CLOCK_SKEW_SECONDS = 300
@@ -129,3 +137,117 @@ def extract_telegram_user_from_init_data(init_data: str) -> dict[str, Any] | Non
User data dict with id, first_name, last_name, username, etc. or None if invalid
"""
return validate_telegram_init_data(init_data)
# JWKS cache (module-level, refreshed periodically)
_jwks_cache: dict[str, Any] = {}
_jwks_cache_expiry: datetime | None = None
_JWKS_CACHE_TTL_SECONDS = 3600 # 1 hour
_JWKS_URL = 'https://oauth.telegram.org/.well-known/jwks.json'
_OIDC_ISSUER = 'https://oauth.telegram.org'
_jwks_lock = asyncio.Lock()
_jwks_last_force_refresh: datetime | None = None
_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS = 30
def _build_public_keys(jwks_data: dict[str, Any]) -> dict[str, Any]:
"""Build public key mapping from JWKS data."""
public_keys: dict[str, Any] = {}
for key_data in jwks_data.get('keys', []):
kid = key_data.get('kid')
if kid:
public_keys[kid] = pyjwt.algorithms.RSAAlgorithm.from_jwk(key_data)
return public_keys
async def _get_jwks(force: bool = False) -> dict[str, Any]:
"""Fetch and cache Telegram OIDC JWKS keys."""
global _jwks_cache, _jwks_cache_expiry
now = datetime.now(UTC)
if not force and _jwks_cache and _jwks_cache_expiry and now < _jwks_cache_expiry:
return _jwks_cache
async with _jwks_lock:
# Double-check after acquiring lock
now = datetime.now(UTC)
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:
response = await client.get(_JWKS_URL)
response.raise_for_status()
_jwks_cache = response.json()
_jwks_cache_expiry = now + timedelta(seconds=_JWKS_CACHE_TTL_SECONDS)
return _jwks_cache
async def _force_refresh_jwks(kid: str) -> dict[str, Any] | None:
"""Force JWKS refresh with cooldown protection. Returns refreshed JWKS or None if on cooldown."""
global _jwks_cache_expiry, _jwks_last_force_refresh
async with _jwks_lock:
now = datetime.now(UTC)
if (
_jwks_last_force_refresh
and (now - _jwks_last_force_refresh).total_seconds() < _JWKS_FORCE_REFRESH_COOLDOWN_SECONDS
):
logger.warning('Telegram OIDC: JWKS force refresh on cooldown', kid=kid)
return None
_jwks_last_force_refresh = now
_jwks_cache_expiry = None
return await _get_jwks(force=True)
async def validate_telegram_oidc_token(id_token: str, client_id: str) -> dict[str, Any] | None:
"""
Validate a Telegram OIDC id_token using JWKS.
Args:
id_token: JWT id_token from Telegram OIDC flow
client_id: Expected audience (bot's numeric ID as string)
Returns:
Decoded claims dict if valid, None otherwise.
Claims include: sub, id, name, preferred_username, picture, iss, aud, exp, iat
"""
try:
# Build public keys from JWKS
jwks_data = await _get_jwks()
public_keys = _build_public_keys(jwks_data)
# Decode header to get kid
unverified_header = pyjwt.get_unverified_header(id_token)
kid = unverified_header.get('kid')
# If kid not found, force JWKS refresh (key rotation) with cooldown
if kid and kid not in public_keys:
refreshed = await _force_refresh_jwks(kid)
if refreshed:
public_keys = _build_public_keys(refreshed)
if not kid or kid not in public_keys:
logger.warning('Telegram OIDC: unknown kid in id_token', kid=kid)
return None
claims = pyjwt.decode(
id_token,
key=public_keys[kid],
algorithms=['RS256'],
audience=client_id,
issuer=_OIDC_ISSUER,
options={'require': ['exp', 'iat', 'iss', 'aud', 'sub']},
)
return claims
except pyjwt.ExpiredSignatureError:
logger.warning('Telegram OIDC: id_token expired')
return None
except pyjwt.InvalidTokenError as e:
logger.warning('Telegram OIDC: invalid id_token', error=str(e))
return None
except httpx.HTTPError as e:
logger.error('Telegram OIDC: failed to fetch JWKS', error=str(e))
return None
+10
View File
@@ -11,6 +11,8 @@ from .admin_button_styles import router as admin_button_styles_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_channels import router as admin_channels_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_landings import router as admin_landings_router
from .admin_menu_layout import router as admin_menu_layout_router
from .admin_partners import router as admin_partners_router
from .admin_payment_methods import router as admin_payment_methods_router
from .admin_payments import router as admin_payments_router
@@ -35,7 +37,9 @@ from .auth import router as auth_router
from .balance import router as balance_router
from .branding import router as branding_router
from .contests import router as contests_router
from .gift import router as gift_router
from .info import router as info_router
from .landing import router as landing_router
from .media import router as media_router
from .notifications import router as notifications_router
from .oauth import router as oauth_router
@@ -78,11 +82,15 @@ router.include_router(promo_router)
router.include_router(notifications_router)
router.include_router(info_router)
router.include_router(branding_router)
router.include_router(landing_router)
router.include_router(media_router)
# Wheel routes
router.include_router(wheel_router)
# Gift routes
router.include_router(gift_router)
# Admin routes (notifications router MUST be before tickets router to avoid route conflict)
router.include_router(admin_ticket_notifications_router)
router.include_router(admin_tickets_router)
@@ -101,6 +109,7 @@ router.include_router(admin_partners_router)
router.include_router(admin_withdrawals_router)
router.include_router(admin_users_router)
router.include_router(admin_payment_methods_router)
router.include_router(admin_landings_router)
router.include_router(admin_payments_router)
router.include_router(admin_promo_offers_router)
router.include_router(admin_remnawave_router)
@@ -109,6 +118,7 @@ router.include_router(admin_updates_router)
router.include_router(admin_traffic_router)
router.include_router(admin_pinned_messages_router)
router.include_router(admin_button_styles_router)
router.include_router(admin_menu_layout_router)
router.include_router(admin_channels_router)
router.include_router(admin_apps_router)
router.include_router(admin_roles_router)
+77 -11
View File
@@ -5,6 +5,7 @@ Router 1 (`router`): JWT-protected endpoints for linking/unlinking OAuth provide
Router 2 (`merge_router`): Public endpoints for merge preview and execution.
"""
import hashlib
from datetime import UTC, datetime
from typing import Literal, NotRequired, TypedDict
@@ -14,6 +15,8 @@ from pydantic import BaseModel, Field, model_validator
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.system_setting import get_setting_value
from app.database.crud.user import (
OAUTH_PROVIDER_COLUMNS,
clear_user_oauth_provider_id,
@@ -24,7 +27,7 @@ from app.database.crud.user import (
)
from app.database.models import User
from app.services.account_merge_service import compute_auth_methods, execute_merge, get_merge_preview
from app.utils.cache import RateLimitCache
from app.utils.cache import RateLimitCache, TokenReplayCache
from ..auth.merge_service import (
MERGE_TOKEN_TTL_SECONDS,
@@ -38,7 +41,11 @@ from ..auth.oauth_providers import (
get_provider,
validate_oauth_state,
)
from ..auth.telegram_auth import validate_telegram_init_data, validate_telegram_login_widget
from ..auth.telegram_auth import (
validate_telegram_init_data,
validate_telegram_login_widget,
validate_telegram_oidc_token,
)
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..ip_utils import get_client_ip
from ..schemas.auth import UserResponse
@@ -70,8 +77,6 @@ class OAuthStateData(TypedDict):
def _get_active_providers() -> list[str]:
"""Вернуть список активных провайдеров аутентификации (только включённые)."""
from app.config import settings
providers: list[str] = ['telegram']
if settings.is_cabinet_email_auth_enabled():
providers.append('email')
@@ -117,10 +122,12 @@ class UnlinkResponse(BaseModel):
class LinkTelegramRequest(BaseModel):
"""Request for linking Telegram account. Supply EITHER init_data OR widget fields."""
"""Request for linking Telegram account. Supply EITHER init_data, id_token, OR widget fields."""
# Mini App: Telegram WebApp initData
init_data: str | None = Field(None, max_length=4096, description='Telegram WebApp initData string')
# OIDC: id_token from Telegram Login popup
id_token: str | None = Field(None, max_length=4096, description='Telegram OIDC id_token (JWT)')
# Login Widget fields
id: int | None = Field(None, description='Telegram user ID from Login Widget')
first_name: str | None = Field(None, max_length=256, description="User's first name")
@@ -133,11 +140,13 @@ class LinkTelegramRequest(BaseModel):
@model_validator(mode='after')
def check_exclusive(self) -> 'LinkTelegramRequest':
has_init = self.init_data is not None
has_oidc = self.id_token is not None
has_widget = self.id is not None or self.hash is not None or self.auth_date is not None
if has_init and has_widget:
raise ValueError('Provide either init_data or Login Widget fields, not both')
if not has_init and not has_widget:
raise ValueError('Provide either init_data or Login Widget fields (id, auth_date, hash)')
modes = sum([has_init, has_oidc, has_widget])
if modes > 1:
raise ValueError('Provide exactly one of: init_data, id_token, or Login Widget fields')
if modes == 0:
raise ValueError('Provide one of: init_data, id_token, or Login Widget fields (id, auth_date, hash)')
if has_widget and not (self.id is not None and self.auth_date is not None and self.hash is not None):
raise ValueError('Login Widget mode requires id, auth_date, and hash fields')
return self
@@ -449,10 +458,20 @@ async def unlink_provider(
@router.post('/link/telegram', response_model=LinkCallbackResponse)
async def link_telegram(
request: LinkTelegramRequest,
raw_request: Request,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> LinkCallbackResponse:
"""Link Telegram account via WebApp initData or Login Widget."""
"""Link Telegram account via WebApp initData, OIDC id_token, or Login Widget."""
# Rate limit
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'link_telegram', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
# 1. Already has Telegram linked?
if user.telegram_id:
raise HTTPException(
@@ -478,6 +497,53 @@ async def link_telegram(
telegram_username = user_data.get('username')
telegram_first_name = user_data.get('first_name')
telegram_last_name = user_data.get('last_name')
elif request.id_token:
# OIDC flow: validate id_token via JWKS
oidc_enabled_val = await get_setting_value(db, 'TELEGRAM_OIDC_ENABLED')
oidc_client_id_val = await get_setting_value(db, 'TELEGRAM_OIDC_CLIENT_ID')
oidc_client_id = oidc_client_id_val or settings.TELEGRAM_OIDC_CLIENT_ID
oidc_enabled = (
oidc_enabled_val.lower() == 'true' if oidc_enabled_val is not None else settings.TELEGRAM_OIDC_ENABLED
) and bool(oidc_client_id)
if not oidc_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Telegram OIDC is not configured',
)
claims = await validate_telegram_oidc_token(request.id_token, oidc_client_id)
if not claims:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid or expired Telegram OIDC token',
)
# Replay detection
token_hash = hashlib.sha256(request.id_token.encode()).hexdigest()
token_ttl = max(int(claims.get('exp', 0) - datetime.now(UTC).timestamp()), 60)
if await TokenReplayCache.is_token_replayed(token_hash, ttl=min(token_ttl, 600)):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid or expired Telegram OIDC token',
)
try:
telegram_id = int(claims.get('id', claims.get('sub', 0)))
except (ValueError, TypeError) as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid user ID in OIDC claims',
) from exc
if not telegram_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Missing user ID in OIDC claims',
)
telegram_username = claims.get('preferred_username')
telegram_first_name = claims.get('name', claims.get('given_name', ''))
telegram_last_name = claims.get('family_name')
elif request.id is not None and request.hash is not None and request.auth_date is not None:
# Login Widget flow: validate widget hash
widget_data = {
@@ -506,7 +572,7 @@ async def link_telegram(
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Provide either init_data (Mini App) or Login Widget fields (id, auth_date, hash)',
detail='Provide init_data (Mini App), id_token (OIDC), or Login Widget fields (id, auth_date, hash)',
)
# 3. Check if telegram_id is linked to ANOTHER user
+138 -26
View File
@@ -37,7 +37,7 @@ TEMPLATE_TYPES = [
'zh': '余额充值通知',
'ua': 'Сповіщення про поповнення балансу',
},
'context_vars': ['amount', 'balance'],
'context_vars': ['formatted_amount', 'formatted_balance', 'amount_rubles', 'new_balance_rubles'],
},
{
'type': 'balance_change',
@@ -48,7 +48,7 @@ TEMPLATE_TYPES = [
'zh': '余额变动通知',
'ua': 'Сповіщення про зміну балансу',
},
'context_vars': ['amount', 'balance'],
'context_vars': ['formatted_amount', 'formatted_balance', 'amount_rubles', 'new_balance_rubles'],
},
{
'type': 'subscription_expiring',
@@ -96,7 +96,7 @@ TEMPLATE_TYPES = [
'zh': '订阅已续期通知',
'ua': 'Сповіщення про продовження підписки',
},
'context_vars': ['new_end_date', 'tariff_name'],
'context_vars': ['new_expires_at', 'tariff_name', 'traffic_limit_gb', 'device_limit'],
},
{
'type': 'subscription_activated',
@@ -112,7 +112,7 @@ TEMPLATE_TYPES = [
'zh': '订阅已激活通知',
'ua': 'Сповіщення про активацію підписки',
},
'context_vars': ['tariff_name', 'end_date'],
'context_vars': ['expires_at', 'tariff_name', 'traffic_limit_gb', 'device_limit'],
},
{
'type': 'autopay_success',
@@ -128,7 +128,7 @@ TEMPLATE_TYPES = [
'zh': '自动续费成功通知',
'ua': 'Сповіщення про успішний автоплатіж',
},
'context_vars': ['amount', 'balance', 'new_end_date'],
'context_vars': ['formatted_amount', 'amount_rubles', 'new_expires_at'],
},
{
'type': 'autopay_failed',
@@ -160,7 +160,7 @@ TEMPLATE_TYPES = [
'zh': '自动续费余额不足通知',
'ua': 'Сповіщення про нестачу коштів для автоплатежу',
},
'context_vars': ['required_amount', 'balance'],
'context_vars': ['required_amount', 'current_balance'],
},
{
'type': 'daily_debit',
@@ -171,7 +171,7 @@ TEMPLATE_TYPES = [
'zh': '每日扣费通知',
'ua': 'Сповіщення про добове списання',
},
'context_vars': ['amount', 'balance'],
'context_vars': ['formatted_amount', 'formatted_balance', 'amount_rubles', 'new_balance_rubles'],
},
{
'type': 'daily_insufficient_funds',
@@ -187,7 +187,7 @@ TEMPLATE_TYPES = [
'zh': '每日扣费余额不足通知',
'ua': 'Сповіщення про нестачу коштів для добового списання',
},
'context_vars': ['required_amount', 'balance'],
'context_vars': ['required_amount', 'current_balance'],
},
{
'type': 'ban_notification',
@@ -236,7 +236,7 @@ TEMPLATE_TYPES = [
'zh': '推荐奖励通知',
'ua': 'Сповіщення про нарахування реферального бонусу',
},
'context_vars': ['amount', 'referral_name'],
'context_vars': ['formatted_bonus', 'bonus_rubles', 'referral_name'],
},
{
'type': 'referral_registered',
@@ -258,7 +258,7 @@ TEMPLATE_TYPES = [
'zh': '流量重置通知',
'ua': 'Сповіщення про скидання трафіку',
},
'context_vars': ['traffic_limit'],
'context_vars': ['reset_gb', 'current_limit_gb'],
},
{
'type': 'payment_received',
@@ -269,7 +269,7 @@ TEMPLATE_TYPES = [
'zh': '收到付款通知',
'ua': 'Сповіщення про отримання платежу',
},
'context_vars': ['amount', 'payment_method'],
'context_vars': ['formatted_amount', 'payment_method'],
},
{
'type': 'email_verification',
@@ -298,6 +298,77 @@ TEMPLATE_TYPES = [
},
'context_vars': ['username', 'reset_url', 'expire_hours'],
},
{
'type': 'guest_subscription_delivered',
'label': {
'ru': 'Быстрая покупка: подписка доставлена',
'en': 'Quick Purchase: Subscription Delivered',
'zh': '快捷购买:订阅已交付',
'ua': 'Швидка покупка: підписка доставлена',
},
'description': {
'ru': 'Письмо покупателю после успешной оплаты через лендинг',
'en': 'Email to buyer after successful landing page payment',
'zh': '通过落地页成功付款后发送给买家的邮件',
'ua': 'Лист покупцю після успішної оплати через лендінг',
},
'context_vars': ['tariff_name', 'period_days', 'cabinet_url'],
},
{
'type': 'guest_activation_required',
'label': {
'ru': 'Быстрая покупка: требуется активация',
'en': 'Quick Purchase: Activation Required',
'zh': '快捷购买:需要激活',
'ua': 'Швидка покупка: потрібна активація',
},
'description': {
'ru': 'Письмо когда у покупателя уже есть активная подписка',
'en': 'Email when buyer already has an active subscription',
'zh': '买家已有活跃订阅时发送的邮件',
'ua': 'Лист коли у покупця вже є активна підписка',
},
'context_vars': ['tariff_name', 'period_days', 'success_page_url', 'gift_message', 'is_gift'],
},
{
'type': 'guest_gift_received',
'label': {
'ru': 'Быстрая покупка: подарок получен',
'en': 'Quick Purchase: Gift Received',
'zh': '快捷购买:收到礼物',
'ua': 'Швидка покупка: подарунок отримано',
},
'description': {
'ru': 'Письмо получателю подарочной подписки',
'en': 'Email to gift subscription recipient',
'zh': '发送给礼物订阅接收者的邮件',
'ua': 'Лист отримувачу подарункової підписки',
},
'context_vars': [
'tariff_name',
'period_days',
'cabinet_url',
'gift_message',
'cabinet_email',
'cabinet_password',
],
},
{
'type': 'guest_cabinet_credentials',
'label': {
'ru': 'Быстрая покупка: данные для входа',
'en': 'Quick Purchase: Login Credentials',
'zh': '快捷购买:登录凭据',
'ua': 'Швидка покупка: дані для входу',
},
'description': {
'ru': 'Письмо с логином и паролем для личного кабинета',
'en': 'Email with login credentials for the cabinet',
'zh': '包含个人中心登录信息的邮件',
'ua': 'Лист з логіном та паролем для особистого кабінету',
},
'context_vars': ['tariff_name', 'period_days', 'cabinet_url', 'cabinet_email', 'cabinet_password'],
},
]
SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
@@ -315,26 +386,68 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
},
'subscription_expiring': {'days_left': 3, 'expires_at': '2025-01-30'},
'subscription_expired': {},
'subscription_renewed': {'new_end_date': '2025-02-28', 'tariff_name': 'Premium'},
'subscription_activated': {'tariff_name': 'Premium', 'end_date': '2025-02-28'},
'autopay_success': {'formatted_amount': '300.00 ₽', 'formatted_balance': '200.00 ₽', 'new_end_date': '2025-02-28'},
'subscription_renewed': {
'new_expires_at': '2025-02-28',
'tariff_name': 'Premium',
'traffic_limit_gb': 100,
'device_limit': 3,
},
'subscription_activated': {
'expires_at': '2025-02-28',
'tariff_name': 'Premium',
'traffic_limit_gb': 100,
'device_limit': 3,
},
'autopay_success': {'formatted_amount': '300.00 ₽', 'amount_rubles': 300, 'new_expires_at': '2025-02-28'},
'autopay_failed': {'reason': 'Card declined'},
'autopay_insufficient_funds': {'formatted_required': '300.00 ₽', 'formatted_balance': '50.00 ₽'},
'daily_debit': {'formatted_amount': '10.00 ₽', 'formatted_balance': '490.00 ₽'},
'daily_insufficient_funds': {'formatted_required': '10.00 ₽', 'formatted_balance': '5.00 ₽'},
'autopay_insufficient_funds': {'required_amount': '300.00 ₽', 'current_balance': '50.00 ₽'},
'daily_debit': {
'formatted_amount': '10.00 ₽',
'formatted_balance': '490.00 ₽',
'amount_rubles': 10,
'new_balance_rubles': 490,
},
'daily_insufficient_funds': {'required_amount': '10.00 ₽', 'current_balance': '5.00 ₽'},
'ban_notification': {'reason': 'Violation of terms of service'},
'unban_notification': {},
'warning_notification': {'message': 'Please review our terms of service'},
'referral_bonus': {'formatted_amount': '100.00 ₽', 'referral_name': 'John'},
'referral_bonus': {'formatted_bonus': '100.00 ₽', 'bonus_rubles': 100, 'referral_name': 'John'},
'referral_registered': {'referral_name': 'John'},
'traffic_reset': {'traffic_limit': '100 GB'},
'payment_received': {'formatted_amount': '500.00 ₽', 'payment_method': 'YooKassa'},
'traffic_reset': {'reset_gb': 50, 'current_limit_gb': 100},
'payment_received': {'formatted_amount': '500.00 ₽', 'amount_rubles': 500, 'payment_method': 'YooKassa'},
'email_verification': {
'username': 'John',
'verification_url': 'https://example.com/verify?token=abc123',
'expire_hours': 24,
},
'password_reset': {'username': 'John', 'reset_url': 'https://example.com/reset?token=abc123', 'expire_hours': 1},
'guest_subscription_delivered': {
'tariff_name': 'Premium',
'period_days': 30,
'cabinet_url': 'https://example.com/cabinet',
},
'guest_activation_required': {
'tariff_name': 'Premium',
'period_days': 30,
'success_page_url': 'https://example.com/cabinet/buy/success/abc123',
'is_gift': True,
'gift_message': 'Happy birthday!',
},
'guest_gift_received': {
'tariff_name': 'Premium',
'period_days': 30,
'cabinet_url': 'https://example.com/cabinet',
'gift_message': 'Happy birthday!',
'cabinet_email': 'recipient@example.com',
'cabinet_password': 'SecurePass123',
},
'guest_cabinet_credentials': {
'tariff_name': 'Premium',
'period_days': 30,
'cabinet_url': 'https://example.com/cabinet',
'cabinet_email': 'user@example.com',
'cabinet_password': 'SecurePass123',
},
}
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua', 'fa']
@@ -618,14 +731,13 @@ async def send_test_email(
sample_context = SAMPLE_CONTEXTS.get(notification_type, {})
templates_instance = EmailNotificationTemplates()
# Check for DB override
from ..services.email_template_overrides import get_template_override
# Check for DB override (get_rendered_override substitutes sample context vars)
from ..services.email_template_overrides import get_rendered_override
override = await get_template_override(notification_type, language, db)
rendered = await get_rendered_override(notification_type, language, sample_context, db)
if override:
subject = override['subject']
body_html = templates_instance._get_base_template(override['body_html'], language)
if rendered:
subject, body_html = rendered
else:
try:
from app.services.notification_delivery_service import NotificationType
File diff suppressed because it is too large Load Diff
+398
View File
@@ -0,0 +1,398 @@
"""Admin routes for cabinet menu layout configuration (rows + custom URL buttons).
Serves a MERGED view combining ``CABINET_MENU_LAYOUT`` (row arrangement, custom buttons)
and ``CABINET_BUTTON_STYLES`` (per-section style/emoji/enabled/labels) to the frontend.
On save, splits the payload back into two SystemSetting keys.
"""
import json
import re
from typing import Literal
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.utils.button_styles_cache import (
ALLOWED_STYLE_VALUES,
BOT_LOCALES,
BUTTON_STYLES_KEY,
DEFAULT_BUTTON_STYLES,
get_cached_button_styles,
load_button_styles_cache,
)
from app.utils.menu_layout_cache import (
BUILTIN_SECTIONS,
DEFAULT_MENU_LAYOUT,
MENU_LAYOUT_KEY,
VALID_CUSTOM_BUTTON_STYLES,
get_cached_menu_layout,
load_menu_layout_cache,
)
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/menu-layout', tags=['Admin Menu Layout'])
# ---- Constants ---------------------------------------------------------------
MAX_ROWS = 20
MAX_BUTTONS_PER_ROW = 3
MAX_LABEL_LENGTH = 100
URL_PATTERN = re.compile(r'^https?://')
# ---- Schemas -----------------------------------------------------------------
class ButtonConfig(BaseModel):
"""Configuration for a single button (built-in or custom URL)."""
id: str = Field(max_length=100)
type: Literal['builtin', 'custom']
style: str = Field(default='primary', max_length=20)
icon_custom_emoji_id: str = Field(default='', max_length=100)
enabled: bool = True
labels: dict[str, str] = Field(default_factory=dict, max_length=10)
url: str | None = Field(default=None, max_length=2048)
open_in: Literal['external', 'webapp'] = 'external'
class RowConfig(BaseModel):
"""Configuration for a single row of buttons."""
id: str = Field(max_length=100)
max_per_row: int = Field(default=2, ge=1, le=3)
buttons: list[ButtonConfig] = Field(default_factory=list, max_length=MAX_BUTTONS_PER_ROW)
class MenuConfigResponse(BaseModel):
"""Full merged menu configuration returned to the frontend."""
rows: list[RowConfig]
class MenuConfigUpdateRequest(BaseModel):
"""Full menu configuration submitted by the frontend."""
rows: list[RowConfig] = Field(max_length=MAX_ROWS)
# ---- Helpers -----------------------------------------------------------------
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def _upsert_setting(db: AsyncSession, key: str, value: str) -> None:
"""Insert or update a SystemSetting without committing."""
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
setting = SystemSetting(key=key, value=value)
db.add(setting)
def _build_merged_response(
layout: dict[str, object],
button_styles: dict[str, dict],
) -> MenuConfigResponse:
"""Merge layout rows with button_styles into a unified response.
Built-in buttons get style/emoji/enabled/labels from ``button_styles``.
Custom URL buttons get all config from layout's ``custom_buttons``.
"""
custom_buttons: dict[str, dict] = layout.get('custom_buttons', {})
# Collect row entries sorted numerically (row_1, row_2, ..., row_10, ...)
row_keys = sorted(
(k for k in layout if k.startswith('row_')),
key=lambda k: int(k.split('_', 1)[1]) if k.split('_', 1)[1].isdigit() else 0,
)
rows: list[RowConfig] = []
for row_key in row_keys:
row_data = layout[row_key]
if not isinstance(row_data, dict):
continue
raw_buttons: list[str] = row_data.get('buttons', [])
max_per_row: int = row_data.get('max_per_row', 2)
row_id: str = row_data.get('id', row_key)
merged_buttons: list[ButtonConfig] = []
for btn_id in raw_buttons:
if btn_id in BUILTIN_SECTIONS:
# Built-in: pull style data from button_styles cache
style_cfg = button_styles.get(btn_id, {})
merged_buttons.append(
ButtonConfig(
id=btn_id,
type='builtin',
style=style_cfg.get('style', 'primary'),
icon_custom_emoji_id=style_cfg.get('icon_custom_emoji_id', ''),
enabled=style_cfg.get('enabled', True),
labels=style_cfg.get('labels', {}),
),
)
elif btn_id.startswith('custom_') and btn_id in custom_buttons:
# Custom URL button: pull config from layout's custom_buttons
cb = custom_buttons[btn_id]
merged_buttons.append(
ButtonConfig(
id=btn_id,
type='custom',
style=cb.get('style', 'primary'),
icon_custom_emoji_id=cb.get('icon_custom_emoji_id', ''),
enabled=cb.get('enabled', True),
labels=cb.get('labels', {}),
url=cb.get('url'),
open_in=cb.get('open_in', 'external'),
),
)
rows.append(
RowConfig(
id=row_id,
max_per_row=max_per_row,
buttons=merged_buttons,
),
)
return MenuConfigResponse(rows=rows)
def _split_update(
rows: list[RowConfig],
) -> tuple[dict[str, object], dict[str, dict]]:
"""Split a flat list of RowConfig back into layout_data and button_styles_updates.
Returns:
(layout_data, button_styles_updates)
- layout_data: rows + custom_buttons for ``CABINET_MENU_LAYOUT``
- button_styles_updates: ``{section: {style, icon_custom_emoji_id, enabled, labels}}``
for built-in sections only
"""
layout_data: dict[str, object] = {}
custom_buttons: dict[str, dict] = {}
button_styles_updates: dict[str, dict] = {}
for idx, row in enumerate(rows, start=1):
row_key = f'row_{idx}'
button_ids: list[str] = []
for btn in row.buttons:
button_ids.append(btn.id)
if btn.type == 'builtin' and btn.id in BUILTIN_SECTIONS:
button_styles_updates[btn.id] = {
'style': btn.style,
'icon_custom_emoji_id': btn.icon_custom_emoji_id,
'enabled': btn.enabled,
'labels': btn.labels,
}
elif btn.type == 'custom' and btn.id.startswith('custom_'):
custom_buttons[btn.id] = {
'id': btn.id,
'url': btn.url or '',
'style': btn.style,
'icon_custom_emoji_id': btn.icon_custom_emoji_id,
'enabled': btn.enabled,
'labels': btn.labels,
'open_in': btn.open_in,
}
layout_data[row_key] = {
'id': row.id or row_key,
'buttons': button_ids,
'max_per_row': row.max_per_row,
}
layout_data['custom_buttons'] = custom_buttons
return layout_data, button_styles_updates
def _validate_update_payload(rows: list[RowConfig]) -> None:
"""Validate the full update payload. Raises HTTPException on failure."""
if len(rows) > MAX_ROWS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Too many rows: {len(rows)}. Maximum allowed: {MAX_ROWS}.',
)
# Check for duplicate button IDs across all rows
seen_ids: set[str] = set()
for row in rows:
for btn in row.buttons:
if btn.id in seen_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Duplicate button ID: "{btn.id}". Each button can only appear once.',
)
seen_ids.add(btn.id)
for row in rows:
if len(row.buttons) > MAX_BUTTONS_PER_ROW:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Row "{row.id}" has {len(row.buttons)} buttons. Maximum per row: {MAX_BUTTONS_PER_ROW}.',
)
for btn in row.buttons:
# Validate button type consistency
if btn.type == 'builtin' and btn.id not in BUILTIN_SECTIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Unknown built-in section: "{btn.id}".',
)
if btn.type == 'custom' and not btn.id.startswith('custom_'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Custom button id must start with "custom_": "{btn.id}".',
)
# Validate URL for custom buttons
if btn.type == 'custom':
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://.',
)
if btn.open_in == 'webapp' and not btn.url.startswith('https://'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Custom button "{btn.id}" with webapp mode requires an https:// URL.',
)
# Validate style
all_allowed = ALLOWED_STYLE_VALUES | VALID_CUSTOM_BUTTON_STYLES
if btn.style not in all_allowed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid style "{btn.style}" for button "{btn.id}". '
f'Allowed: {", ".join(sorted(all_allowed))}.',
)
# Validate labels
for locale_key, label_val in btn.labels.items():
if locale_key not in BOT_LOCALES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid locale "{locale_key}" for button "{btn.id}". '
f'Allowed: {", ".join(BOT_LOCALES)}.',
)
if not isinstance(label_val, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label value for locale "{locale_key}" must be a string.',
)
if len(label_val.strip()) > MAX_LABEL_LENGTH:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label for locale "{locale_key}" on button "{btn.id}" '
f'exceeds {MAX_LABEL_LENGTH} characters.',
)
# ---- Routes ------------------------------------------------------------------
@router.get('', response_model=MenuConfigResponse)
async def get_menu_layout(
_admin: User = Depends(require_permission('settings:read')),
):
"""Return merged menu layout config (rows + button styles). Admin only."""
layout = get_cached_menu_layout()
button_styles = get_cached_button_styles()
return _build_merged_response(layout, button_styles)
@router.put('', response_model=MenuConfigResponse)
async def update_menu_layout(
payload: MenuConfigUpdateRequest,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Save full menu layout config. Splits into layout + button styles. Admin only."""
_validate_update_payload(payload.rows)
layout_data, button_styles_updates = _split_update(payload.rows)
# Save layout to CABINET_MENU_LAYOUT (without committing)
await _upsert_setting(db, MENU_LAYOUT_KEY, json.dumps(layout_data))
# Merge button styles updates with existing styles (don't overwrite sections not in request)
if button_styles_updates:
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
current_styles: dict[str, dict] = {}
if raw:
try:
current_styles = json.loads(raw)
except (json.JSONDecodeError, TypeError):
current_styles = {}
for section, updates in button_styles_updates.items():
current_styles[section] = updates
await _upsert_setting(db, BUTTON_STYLES_KEY, json.dumps(current_styles))
# Single atomic commit for both settings
await db.commit()
# Refresh caches after commit
await load_button_styles_cache()
await load_menu_layout_cache()
logger.info(
'Admin updated menu layout',
telegram_id=admin.telegram_id,
rows_count=len(payload.rows),
custom_buttons_count=len(layout_data.get('custom_buttons', {})),
)
# Return merged response from fresh caches
layout = get_cached_menu_layout()
button_styles = get_cached_button_styles()
return _build_merged_response(layout, button_styles)
@router.post('/reset', response_model=MenuConfigResponse)
async def reset_menu_layout(
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset menu layout AND button styles to defaults. Admin only."""
await _upsert_setting(db, MENU_LAYOUT_KEY, json.dumps(DEFAULT_MENU_LAYOUT))
await _upsert_setting(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
# Single atomic commit for both settings
await db.commit()
# Refresh caches after commit
await load_button_styles_cache()
await load_menu_layout_cache()
logger.info('Admin reset menu layout and button styles to defaults', telegram_id=admin.telegram_id)
layout = get_cached_menu_layout()
button_styles = get_cached_button_styles()
return _build_merged_response(layout, button_styles)
+4 -3
View File
@@ -85,6 +85,7 @@ async def update_partner_settings(
admin: User = Depends(require_permission('partners:settings')),
):
"""Update partner system settings."""
import asyncio
from pathlib import Path
# Update in-memory settings
@@ -104,8 +105,8 @@ async def update_partner_settings(
# Persist to .env file
try:
env_file = Path('.env')
if env_file.exists():
lines = env_file.read_text().splitlines()
if await asyncio.to_thread(env_file.exists):
lines = (await asyncio.to_thread(env_file.read_text)).splitlines()
updates: dict[str, str] = {}
if request.withdrawal_enabled is not None:
@@ -143,7 +144,7 @@ async def update_partner_settings(
if key not in updated_keys:
new_lines.append(f'{key}={value}')
env_file.write_text('\n'.join(new_lines) + '\n')
await asyncio.to_thread(env_file.write_text, '\n'.join(new_lines) + '\n')
logger.info('Updated partner settings in .env file', admin_id=admin.id)
except Exception as e:
logger.warning('Failed to update .env file', error=e)
+15 -2
View File
@@ -4,7 +4,7 @@ from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
@@ -60,10 +60,23 @@ class PaymentMethodConfigResponse(BaseModel):
class PaymentMethodConfigUpdateRequest(BaseModel):
is_enabled: bool | None = None
display_name: str | None = Field(default=None, description='Null to reset to default')
sub_options: dict | None = None
sub_options: dict[str, bool] | None = None
min_amount_kopeks: int | None = Field(default=None, ge=0)
max_amount_kopeks: int | None = Field(default=None, ge=0)
user_type_filter: str | None = Field(default=None, pattern='^(all|telegram|email)$')
@field_validator('sub_options', mode='before')
@classmethod
def validate_sub_options(cls, v: dict[str, bool] | None) -> dict[str, bool] | None:
if not v:
return None
if len(v) > 20:
raise ValueError('sub_options cannot have more than 20 keys')
for key in v:
if not isinstance(key, str) or len(key) > 50:
raise ValueError('sub_options keys must be strings of at most 50 characters')
return v
first_topup_filter: str | None = Field(default=None, pattern='^(any|yes|no)$')
promo_group_filter_mode: str | None = Field(default=None, pattern='^(all|selected)$')
allowed_promo_group_ids: list[int] | None = None
+1 -1
View File
@@ -210,7 +210,7 @@ def _is_checkable(record: PendingPayment) -> bool:
if record.method == PaymentMethod.YOOKASSA:
return status_str in {'pending', 'waiting_for_capture'}
if record.method == PaymentMethod.CRYPTOBOT:
return status_str in {'active'}
return status_str == 'active'
if record.method == PaymentMethod.CLOUDPAYMENTS:
return status_str in {'pending', 'authorized'}
if record.method == PaymentMethod.FREEKASSA:
+44 -25
View File
@@ -489,44 +489,63 @@ async def admin_deactivate_discount_promocode(
admin: User = Depends(require_permission('promocodes:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> DeactivateDiscountResponse:
"""Admin: deactivate a user's active discount promo code."""
"""Admin: deactivate a user's active discount (promo code or promo offer)."""
from app.database.crud.user import get_user_by_id as get_user
target_user = await get_user(db, user_id)
if not target_user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
from app.services.promocode_service import PromoCodeService
current_discount = getattr(target_user, 'promo_offer_discount_percent', 0) or 0
source = getattr(target_user, 'promo_offer_discount_source', None)
service = PromoCodeService()
result = await service.deactivate_discount_promocode(
db=db,
user_id=user_id,
admin_initiated=True,
)
if current_discount <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'User has no active discount')
if result['success']:
return DeactivateDiscountResponse(
success=True,
message=f'Discount promo code deactivated for user {user_id}',
deactivated_code=result.get('deactivated_code'),
discount_percent=result.get('discount_percent', 0),
# If source is a promo code, use the service to properly rollback usage
if source and source.startswith('promocode:'):
from app.services.promocode_service import PromoCodeService
service = PromoCodeService()
result = await service.deactivate_discount_promocode(
db=db,
user_id=user_id,
admin_initiated=True,
)
error_messages = {
'user_not_found': 'User not found',
'no_active_discount_promocode': 'User has no active discount from a promo code',
'discount_already_expired': 'Discount has already expired (cleaned up)',
'server_error': 'Server error occurred',
}
if result['success']:
return DeactivateDiscountResponse(
success=True,
message=f'Discount promo code deactivated for user {user_id}',
deactivated_code=result.get('deactivated_code'),
discount_percent=result.get('discount_percent', 0),
user_id=user_id,
)
error_code = result.get('error', 'server_error')
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
error_messages = {
'user_not_found': 'User not found',
'no_active_discount_promocode': 'User has no active discount from a promo code',
'discount_already_expired': 'Discount has already expired (cleaned up)',
'server_error': 'Server error occurred',
}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message,
error_code = result.get('error', 'server_error')
raise HTTPException(status.HTTP_400_BAD_REQUEST, error_messages.get(error_code, 'Failed to deactivate'))
# For non-promocode offers (admin offers, etc.) — just clear the fields
old_percent = target_user.promo_offer_discount_percent
target_user.promo_offer_discount_percent = 0
target_user.promo_offer_discount_source = None
target_user.promo_offer_discount_expires_at = None
target_user.updated_at = datetime.now(UTC)
await db.commit()
return DeactivateDiscountResponse(
success=True,
message=f'Promo offer deactivated for user {user_id}',
deactivated_code=None,
discount_percent=old_percent,
user_id=user_id,
)
+39
View File
@@ -177,6 +177,45 @@ async def get_permission_registry(
]
@router.get('/users', response_model=list[AdminWithRolesResponse])
async def list_rbac_users(
admin: User = Depends(require_permission('roles:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all users that have at least one active RBAC role."""
from sqlalchemy import select as _sa_select
from sqlalchemy.orm import selectinload as _sel
from app.database.models import UserRole as _UserRole
result = await db.execute(
_sa_select(_UserRole)
.options(_sel(_UserRole.user), _sel(_UserRole.role))
.where(_UserRole.is_active.is_(True))
.order_by(_UserRole.user_id)
)
assignments = result.scalars().all()
users_map: dict[int, AdminWithRolesResponse] = {}
for a in assignments:
if not a.user:
continue
if a.user_id not in users_map:
users_map[a.user_id] = AdminWithRolesResponse(
user_id=a.user_id,
telegram_id=a.user.telegram_id,
username=a.user.username,
first_name=a.user.first_name,
last_name=a.user.last_name,
email=a.user.email,
role_names=[],
)
if a.role:
users_map[a.user_id].role_names.append(a.role.name)
return list(users_map.values())
@router.get('/roles/{role_id}/users', response_model=list[UserRoleResponse])
async def list_role_users(
role_id: int,
+3 -1
View File
@@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.campaign import get_campaign_statistics, get_campaigns_count, get_campaigns_list
from app.database.crud.server_squad import get_server_statistics
from app.database.crud.subscription import get_subscriptions_statistics
from app.database.crud.transaction import get_revenue_by_period, get_transactions_statistics
from app.database.crud.transaction import REAL_PAYMENT_METHODS, get_revenue_by_period, get_transactions_statistics
from app.database.models import (
ReferralEarning,
Subscription,
@@ -931,6 +931,7 @@ async def get_recent_payments(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= today_start,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
)
)
)
@@ -942,6 +943,7 @@ async def get_recent_payments(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= week_ago,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
)
)
)
+277 -3
View File
@@ -1,9 +1,12 @@
"""Admin routes for managing tariffs in cabinet."""
import asyncio
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.tariff import (
@@ -17,14 +20,16 @@ from app.database.crud.tariff import (
set_tariff_promo_groups,
update_tariff,
)
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Tariff, Transaction, TransactionType, User
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.tariffs import (
ExternalSquadInfoResponse,
PeriodPrice,
PromoGroupInfo,
ServerInfo,
ServerTrafficLimit,
SyncSquadsResponse,
TariffCreateRequest,
TariffDetailResponse,
TariffListItem,
@@ -126,6 +131,7 @@ async def list_tariffs(
is_daily=tariff.is_daily,
daily_price_kopeks=tariff.daily_price_kopeks,
allow_traffic_topup=tariff.allow_traffic_topup,
show_in_gift=tariff.show_in_gift,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
tier_level=tariff.tier_level,
@@ -158,6 +164,30 @@ async def get_available_servers(
]
@router.get('/available-external-squads', response_model=list[ExternalSquadInfoResponse])
async def get_available_external_squads(
admin: User = Depends(require_permission('tariffs:read')),
):
"""Fetch external squads from RemnaWave panel."""
from app.services.remnawave_service import RemnaWaveService
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
squads = await api.get_external_squads()
return [
{
'uuid': s.uuid,
'name': s.name,
'members_count': s.members_count,
}
for s in squads
]
except Exception:
logger.warning('Failed to fetch external squads from RemnaWave', exc_info=True)
return []
@router.put('/order')
async def update_tariff_order(
request: TariffSortOrderRequest,
@@ -238,6 +268,10 @@ async def get_tariff(
daily_price_kopeks=tariff.daily_price_kopeks,
# Режим сброса трафика
traffic_reset_mode=tariff.traffic_reset_mode,
# Внешний сквад
external_squad_uuid=tariff.external_squad_uuid,
# Показывать в подарках
show_in_gift=tariff.show_in_gift,
created_at=tariff.created_at,
updated_at=tariff.updated_at,
)
@@ -276,7 +310,7 @@ async def create_new_tariff(
period_prices=period_prices_dict,
allowed_squads=request.allowed_squads,
server_traffic_limits=server_limits_dict,
promo_group_ids=request.promo_group_ids if request.promo_group_ids else None,
promo_group_ids=request.promo_group_ids or None,
# Произвольное количество дней
custom_days_enabled=request.custom_days_enabled,
price_per_day_kopeks=request.price_per_day_kopeks,
@@ -292,6 +326,10 @@ async def create_new_tariff(
daily_price_kopeks=request.daily_price_kopeks,
# Режим сброса трафика
traffic_reset_mode=request.traffic_reset_mode,
# Внешний сквад
external_squad_uuid=request.external_squad_uuid,
# Показывать в подарках
show_in_gift=request.show_in_gift,
)
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
@@ -318,6 +356,10 @@ async def update_existing_tariff(
detail='Tariff not found',
)
# Capture old values for change detection
old_squads = list(tariff.allowed_squads) if tariff.allowed_squads else []
old_external_squad = tariff.external_squad_uuid
# Build updates dict
updates = {}
if request.name is not None:
@@ -381,6 +423,12 @@ async def update_existing_tariff(
# Режим сброса трафика (None допускается как значение для сброса к глобальной настройке)
if 'traffic_reset_mode' in request.model_fields_set:
updates['traffic_reset_mode'] = request.traffic_reset_mode
# Внешний сквад (None допускается для сброса)
if 'external_squad_uuid' in request.model_fields_set:
updates['external_squad_uuid'] = request.external_squad_uuid
# Показывать в подарках
if request.show_in_gift is not None:
updates['show_in_gift'] = request.show_in_gift
if updates:
await update_tariff(db, tariff, **updates)
@@ -394,6 +442,18 @@ async def update_existing_tariff(
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
# Auto-sync squads to active subscriptions in Remnawave when squads changed
new_squads = tariff.allowed_squads or []
squads_changed = request.allowed_squads is not None and sorted(old_squads) != sorted(new_squads)
ext_squad_changed = (
'external_squad_uuid' in request.model_fields_set and tariff.external_squad_uuid != old_external_squad
)
if squads_changed or ext_squad_changed:
asyncio.create_task(
_background_sync_squads(tariff_id, admin.id),
name=f'sync-squads-tariff-{tariff_id}',
)
return await get_tariff(tariff_id, admin, db)
@@ -554,3 +614,217 @@ async def get_tariff_stats(
revenue_kopeks=revenue_kopeks,
revenue_rubles=revenue_kopeks / 100,
)
async def _background_sync_squads(tariff_id: int, admin_id: int) -> None:
"""Run squad sync in background with its own DB session (fire-and-forget)."""
from app.database.database import AsyncSessionLocal
from app.services.remnawave_service import RemnaWaveService
try:
async with AsyncSessionLocal() as db:
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
return
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(joinedload(Subscription.user))
.where(
and_(
Subscription.tariff_id == tariff_id,
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
User.remnawave_uuid.isnot(None),
)
)
)
subscriptions = list(result.unique().scalars().all())
if not subscriptions:
return
new_squads = tariff.allowed_squads or []
ext_squad_uuid = tariff.external_squad_uuid
service = RemnaWaveService()
updated = 0
failed = 0
async with service.get_api_client() as api:
semaphore = asyncio.Semaphore(5)
async def _sync_one(sub: Subscription) -> None:
nonlocal updated, failed
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
if not remnawave_uuid:
return
async with semaphore:
try:
await api.update_user(
uuid=remnawave_uuid,
active_internal_squads=new_squads,
external_squad_uuid=ext_squad_uuid,
)
sub.connected_squads = new_squads
updated += 1
except Exception as e:
failed += 1
logger.warning(
'Background sync: failed to sync squads for user',
user_id=sub.user_id,
error=str(e),
)
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
await db.commit()
logger.info(
'Background squad sync completed after tariff update',
admin_id=admin_id,
tariff_id=tariff_id,
tariff_name=tariff.name,
total=len(subscriptions),
updated=updated,
failed=failed,
)
except Exception:
logger.exception('Background squad sync failed', tariff_id=tariff_id)
_SYNC_SQUADS_CONCURRENCY = 5
_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES = 10
@router.post('/{tariff_id}/sync-squads', response_model=SyncSquadsResponse)
async def sync_tariff_squads(
tariff_id: int,
admin: User = Depends(require_permission('tariffs:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Sync squads from tariff to all active/trial subscriptions in Remnawave panel.
Updates connected_squads and external_squad_uuid for every active or trial
subscription linked to this tariff. Only users that have a remnawave_uuid
(i.e. already exist in the panel) are touched.
"""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
# Fetch active + trial subscriptions for this tariff whose users exist in Remnawave
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(joinedload(Subscription.user))
.where(
and_(
Subscription.tariff_id == tariff_id,
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
User.remnawave_uuid.isnot(None),
)
)
)
subscriptions = list(result.unique().scalars().all())
if not subscriptions:
return SyncSquadsResponse(
tariff_id=tariff_id,
tariff_name=tariff.name,
total_subscriptions=0,
updated_count=0,
failed_count=0,
skipped_count=0,
)
new_squads = tariff.allowed_squads or []
# None means "clear external squad" — intentional when tariff has none
ext_squad_uuid = tariff.external_squad_uuid
# Sync to Remnawave panel with concurrency limit and circuit breaker
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
updated_count = 0
failed_count = 0
skipped_count = 0
consecutive_failures = 0
errors: list[str] = []
aborted = False
async with service.get_api_client() as api:
semaphore = asyncio.Semaphore(_SYNC_SQUADS_CONCURRENCY)
async def _sync_one(sub: Subscription) -> str:
# Counter mutations are safe: no `await` between read-modify-write
# and the check within each branch (single-threaded asyncio event loop).
nonlocal updated_count, failed_count, skipped_count, consecutive_failures, aborted
if aborted:
skipped_count += 1
return 'skipped'
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
if not remnawave_uuid:
skipped_count += 1
return 'skipped'
async with semaphore:
if aborted:
skipped_count += 1
return 'skipped'
try:
await api.update_user(
uuid=remnawave_uuid,
active_internal_squads=new_squads,
external_squad_uuid=ext_squad_uuid,
)
# Update local DB only on successful API call
sub.connected_squads = new_squads
updated_count += 1
consecutive_failures = 0
return 'ok'
except Exception as e:
failed_count += 1
consecutive_failures += 1
errors.append(f'user_id={sub.user_id}: sync failed')
logger.warning(
'Failed to sync squads for user in Remnawave',
user_id=sub.user_id,
remnawave_uuid=remnawave_uuid,
error=str(e),
)
if consecutive_failures >= _SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES:
aborted = True
errors.append(f'Aborted after {_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES} consecutive failures')
return 'error'
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
# Commit local DB changes only for successfully synced subscriptions
await db.commit()
logger.info(
'Admin synced squads for tariff',
admin_id=admin.id,
tariff_id=tariff_id,
tariff_name=tariff.name,
total=len(subscriptions),
updated=updated_count,
failed=failed_count,
skipped=skipped_count,
)
return SyncSquadsResponse(
tariff_id=tariff_id,
tariff_name=tariff.name,
total_subscriptions=len(subscriptions),
updated_count=updated_count,
failed_count=failed_count,
skipped_count=skipped_count,
errors=errors[:20],
)
+4 -3
View File
@@ -246,6 +246,7 @@ async def update_ticket_settings(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket system settings."""
import asyncio
from pathlib import Path
from app.services.support_settings_service import SupportSettingsService
@@ -280,8 +281,8 @@ async def update_ticket_settings(
# Try to persist to .env file
try:
env_file = Path('.env')
if env_file.exists():
lines = env_file.read_text().splitlines()
if await asyncio.to_thread(env_file.exists):
lines = (await asyncio.to_thread(env_file.read_text)).splitlines()
updates = {}
if request.sla_enabled is not None:
@@ -314,7 +315,7 @@ async def update_ticket_settings(
if key not in updated_keys:
new_lines.append(f'{key}={value}')
env_file.write_text('\n'.join(new_lines) + '\n')
await asyncio.to_thread(env_file.write_text, '\n'.join(new_lines) + '\n')
logger.info('Updated ticket settings in .env file')
except Exception as e:
logger.warning('Failed to update .env file', error=e)
+293 -49
View File
@@ -4,9 +4,11 @@ from datetime import UTC, datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Integer, and_, func, or_, select
from sqlalchemy import Integer, and_, delete as sa_delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.campaign import get_campaign_registration_by_user
from app.database.crud.subscription import (
extend_subscription,
@@ -24,7 +26,9 @@ from app.database.crud.user import (
get_users_statistics,
subtract_user_balance,
)
from app.database.crud.user_promo_group import sync_user_primary_promo_group
from app.database.models import (
GuestPurchase,
PromoGroup,
ReferralEarning,
Subscription,
@@ -34,12 +38,15 @@ from app.database.models import (
Transaction,
TransactionType,
User,
UserPromoGroup,
UserStatus,
)
from app.utils.timezone import panel_datetime_to_utc
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.users import (
AdminUserGiftItem,
AdminUserGiftsResponse,
DeleteDeviceResponse,
DeleteUserRequest,
DeleteUserResponse,
@@ -260,6 +267,13 @@ async def _sync_subscription_to_panel(
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
traffic_limit_bytes = subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0
# Загружаем tariff для определения внешнего сквада
try:
await db.refresh(subscription, ['tariff'])
except Exception:
pass
ext_squad_uuid = subscription.tariff.external_squad_uuid if subscription.tariff else None
changes = {}
async with service.get_api_client() as api:
panel_uuid = user.remnawave_uuid
@@ -304,6 +318,12 @@ async def _sync_subscription_to_panel(
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
# Внешний сквад: синхронизируем из тарифа или сбрасываем
if ext_squad_uuid is not None:
update_kwargs['external_squad_uuid'] = ext_squad_uuid
else:
update_kwargs['external_squad_uuid'] = None
try:
updated_panel_user = await api.update_user(**update_kwargs)
subscription.subscription_url = updated_panel_user.subscription_url
@@ -332,6 +352,8 @@ async def _sync_subscription_to_panel(
}
if hwid_limit is not None:
create_kwargs['hwid_device_limit'] = hwid_limit
if ext_squad_uuid is not None:
create_kwargs['external_squad_uuid'] = ext_squad_uuid
new_panel_user = await api.create_user(**create_kwargs)
user.remnawave_uuid = new_panel_user.uuid
@@ -359,7 +381,7 @@ async def _sync_subscription_to_panel(
except Exception as e:
logger.error('Error syncing user to panel', user_id=user.id, error=e)
return {'error': str(e)}
return {'error': 'Ошибка синхронизации пользователя с панелью'}
# === List & Search ===
@@ -595,14 +617,18 @@ async def get_user_detail(
transactions_result = await db.execute(transactions_q)
transactions = transactions_result.scalars().all()
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
_EXPENSE_TYPES = {
TransactionType.WITHDRAWAL.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
TransactionType.GIFT_PAYMENT.value,
}
recent_transactions = [
UserTransactionItem(
id=t.id,
type=t.type,
amount_kopeks=abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
amount_kopeks=-abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=-abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
description=t.description,
payment_method=t.payment_method,
is_completed=t.is_completed,
@@ -1003,10 +1029,10 @@ async def update_user_subscription(
)
if request.action == 'extend':
if not request.days:
if not request.days or request.days <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Days parameter is required for extend action',
detail='Days must be a positive integer',
)
await extend_subscription(db, subscription, request.days)
@@ -1025,6 +1051,36 @@ async def update_user_subscription(
subscription=await _build_subscription_info_async(db, subscription),
)
if request.action == 'shorten':
if not request.days or request.days <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Days must be a positive integer',
)
# Сокращение через отрицательный аргумент: extend_subscription(-N) уменьшает end_date
await extend_subscription(db, subscription, -request.days)
await db.refresh(subscription)
# Check if subscription expired after shortening
if subscription.end_date <= datetime.now(UTC):
subscription.status = SubscriptionStatus.EXPIRED.value
await db.commit()
await db.refresh(subscription)
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(
'Admin shortened subscription for user by days', admin_id=admin.id, user_id=user_id, days=request.days
)
return UpdateSubscriptionResponse(
success=True,
message=f'Subscription shortened by {request.days} days',
subscription=await _build_subscription_info_async(db, subscription),
)
if request.action == 'set_end_date':
if not request.end_date:
raise HTTPException(
@@ -1066,13 +1122,30 @@ async def update_user_subscription(
detail='Tariff not found',
)
# Preserve extra purchased devices above the old tariff's base limit
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
subscription.tariff_id = request.tariff_id
subscription.traffic_limit_gb = tariff.traffic_limit_gb
subscription.device_limit = tariff.device_limit
subscription.device_limit = calc_device_limit_on_tariff_switch(
current_device_limit=subscription.device_limit,
old_tariff_device_limit=old_tariff.device_limit if old_tariff else None,
new_tariff_device_limit=tariff.device_limit,
max_device_limit=tariff.max_device_limit,
)
# Set squads from tariff
if tariff.allowed_squads:
subscription.connected_squads = tariff.allowed_squads
# Convert trial subscription to paid when switching to a non-trial tariff
if subscription.is_trial and not tariff.is_trial_available:
subscription.is_trial = False
if subscription.end_date and subscription.end_date > datetime.now(UTC):
subscription.status = SubscriptionStatus.ACTIVE.value
logger.info('Converted trial subscription to paid', user_id=user_id, tariff_name=tariff.name)
# Сбрасываем докупленный трафик при смене тарифа
from sqlalchemy import delete as sql_delete
@@ -1080,11 +1153,21 @@ async def update_user_subscription(
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
from app.config import settings
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
subscription.traffic_used_gb = 0.0
# Записываем транзакцию о смене тарифа
from app.database.crud.transaction import create_transaction
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=0,
description=f"Смена тарифа администратором на '{tariff.name}'",
commit=False,
)
await db.commit()
await db.refresh(subscription)
@@ -1196,7 +1279,7 @@ async def update_user_subscription(
await add_subscription_traffic(db, subscription, request.traffic_gb)
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
await reactivate_subscription(db, subscription)
await db.refresh(subscription)
@@ -1204,6 +1287,13 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
logger.info('Admin added traffic for user', admin_id=admin.id, traffic_gb=request.traffic_gb, user_id=user_id)
return UpdateSubscriptionResponse(
@@ -1557,8 +1647,22 @@ async def update_user_promo_group(
)
promo_group_name = promo_group.name
user.promo_group_id = new_promo_group_id
user.updated_at = datetime.now(UTC)
# Update M2M table (authoritative source) — not just the legacy FK column.
# Without this, sync_user_primary_promo_group overwrites the admin change
# on the next transaction.
await db.execute(sa_delete(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
if new_promo_group_id is not None:
db.add(
UserPromoGroup(
user_id=user_id,
promo_group_id=new_promo_group_id,
assigned_by='admin',
)
)
await db.flush()
await sync_user_primary_promo_group(db, user_id)
await db.commit()
await db.refresh(user)
@@ -1703,7 +1807,7 @@ async def delete_user_device(
except Exception as e:
logger.error('Error deleting device for user', hwid=hwid, user_id=user_id, error=e)
return DeleteDeviceResponse(success=False, message=str(e))
return DeleteDeviceResponse(success=False, message='Ошибка удаления устройства')
@router.delete('/{user_id}/devices', response_model=ResetDevicesResponse)
@@ -1747,7 +1851,7 @@ async def reset_user_devices(
except Exception as e:
logger.error('Error resetting devices for user', user_id=user_id, error=e)
return ResetDevicesResponse(success=False, message=str(e))
return ResetDevicesResponse(success=False, message='Ошибка сброса устройств')
# === Delete User ===
@@ -1815,28 +1919,32 @@ async def full_delete_user(
detail='User not found',
)
panel_error: str | None = None
deleted_from_panel = False
# Pre-fetch admin.id to avoid MissingGreenlet after transaction rollback
admin_id_val = admin.id
# UserService.delete_user_account handles both bot DB and Remnawave panel
user_service = UserService()
success = await user_service.delete_user_account(db, user_id, admin_id_val)
if success:
deleted_from_panel = request.delete_from_panel and user.remnawave_uuid is not None
delete_result = await user_service.delete_user_account(
db, user_id, admin_id_val, force_panel_delete=request.delete_from_panel
)
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info('Admin fully deleted user', admin_id=admin_id_val, user_id=user_id, reason_text=reason_text)
logger.info(
'Admin fully deleted user',
admin_id=admin_id_val,
user_id=user_id,
reason_text=reason_text,
bot_deleted=delete_result.bot_deleted,
panel_deleted=delete_result.panel_deleted,
panel_error=delete_result.panel_error,
)
return FullDeleteUserResponse(
success=success,
message='User fully deleted from bot and panel' if success else 'Failed to delete user',
deleted_from_bot=success,
deleted_from_panel=deleted_from_panel,
panel_error=panel_error,
success=delete_result.bot_deleted,
message='User fully deleted from bot and panel' if delete_result.bot_deleted else 'Failed to delete user',
deleted_from_bot=delete_result.bot_deleted,
deleted_from_panel=delete_result.panel_deleted,
panel_error=delete_result.panel_error,
)
@@ -1945,21 +2053,6 @@ async def reset_user_subscription(
panel_deactivated=False,
)
from app.database.crud.subscription import is_active_paid_subscription
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск сброса подписки: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
return ResetSubscriptionResponse(
success=False,
message='Cannot reset active paid subscription. Subscription is still active and paid.',
subscription_deleted=False,
panel_deactivated=False,
)
# Deactivate in Remnawave panel if requested
if request.deactivate_in_panel and user.remnawave_uuid:
try:
@@ -1970,7 +2063,7 @@ async def reset_user_subscription(
if panel_deactivated:
logger.info('Disabled Remnawave user for subscription reset', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
panel_error = str(e)
panel_error = 'Ошибка обработки пользователя в Remnawave'
logger.warning('Failed to disable Remnawave user during subscription reset', error=e)
# Delete subscription from database
@@ -2040,7 +2133,7 @@ async def disable_user(
if panel_deactivated:
logger.info('Disabled Remnawave user', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
panel_error = str(e)
panel_error = 'Ошибка обработки пользователя в Remnawave'
logger.warning('Failed to disable Remnawave user', error=e)
# Deactivate subscription in bot database (skip if active paid subscription)
@@ -2144,14 +2237,18 @@ async def get_user_transactions(
result = await db.execute(query)
transactions = result.scalars().all()
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
_EXPENSE_TYPES = {
TransactionType.WITHDRAWAL.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
TransactionType.GIFT_PAYMENT.value,
}
items = [
UserTransactionItem(
id=t.id,
type=t.type,
amount_kopeks=abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
amount_kopeks=-abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=-abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
description=t.description,
payment_method=t.payment_method,
is_completed=t.is_completed,
@@ -2419,8 +2516,25 @@ async def sync_user_from_panel(
if panel_user.expire_at:
panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at)
sub_end_utc = sub.end_date if sub.end_date and sub.end_date.tzinfo else sub.end_date
sub_end_utc = sub.end_date
if sub_end_utc is not None and sub_end_utc.tzinfo is None:
sub_end_utc = sub_end_utc.replace(tzinfo=UTC)
if sub_end_utc != panel_expire_utc:
# Предупреждаем если локальная дата новее панельной
# (например, автопокупка уже продлила подписку)
if sub_end_utc and panel_expire_utc and sub_end_utc > panel_expire_utc:
logger.warning(
'Sync: локальная end_date новее панельной, перезаписываем. '
'Возможно автопокупка уже продлила подписку.',
user_id=user_id,
local_end_date=sub_end_utc.isoformat(),
panel_expire_at=panel_expire_utc.isoformat(),
)
errors.append(
f'Warning: local end_date ({sub_end_utc.isoformat()}) is newer than '
f'panel expire_at ({panel_expire_utc.isoformat()}). '
f'Panel value applied — check if auto-purchase extended subscription.'
)
changes['end_date'] = {
'old': sub.end_date.isoformat() if sub.end_date else None,
'new': panel_expire_utc.isoformat(),
@@ -2605,6 +2719,13 @@ async def sync_user_to_panel(
hwid_limit = resolve_hwid_device_limit_for_payload(sub)
traffic_limit_bytes = sub.traffic_limit_gb * (1024**3) if sub.traffic_limit_gb > 0 else 0
# Загружаем tariff для внешнего сквада
try:
await db.refresh(sub, ['tariff'])
except Exception:
pass
ext_squad_uuid = sub.tariff.external_squad_uuid if sub.tariff else None
async with service.get_api_client() as api:
# Validate existing UUID
if panel_uuid:
@@ -2656,6 +2777,12 @@ async def sync_user_to_panel(
update_kwargs['hwid_device_limit'] = hwid_limit
changes['device_limit'] = hwid_limit
# Внешний сквад: синхронизируем из тарифа или сбрасываем
if ext_squad_uuid is not None:
update_kwargs['external_squad_uuid'] = ext_squad_uuid
else:
update_kwargs['external_squad_uuid'] = None
try:
await api.update_user(**update_kwargs)
action = 'updated'
@@ -2682,6 +2809,8 @@ async def sync_user_to_panel(
if hwid_limit is not None:
create_kwargs['hwid_device_limit'] = hwid_limit
if ext_squad_uuid is not None:
create_kwargs['external_squad_uuid'] = ext_squad_uuid
new_panel_user = await api.create_user(**create_kwargs)
panel_uuid = new_panel_user.uuid
@@ -2719,3 +2848,118 @@ async def sync_user_to_panel(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Sync error: {e!s}',
)
# === User Gifts ===
@router.get('/{user_id}/gifts', response_model=AdminUserGiftsResponse)
async def get_user_gifts(
user_id: int,
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> AdminUserGiftsResponse:
"""Get all gift subscriptions sent and received by user."""
from sqlalchemy.orm import noload
# Lightweight existence check (avoids eager-loading all User relationships)
user_exists = await db.execute(select(User.id).where(User.id == user_id))
if not user_exists.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
# True totals via COUNT queries
sent_total = (
await db.execute(
select(func.count(GuestPurchase.id)).where(
GuestPurchase.buyer_user_id == user_id,
GuestPurchase.is_gift.is_(True),
)
)
).scalar() or 0
received_total = (
await db.execute(
select(func.count(GuestPurchase.id)).where(
GuestPurchase.user_id == user_id,
GuestPurchase.is_gift.is_(True),
)
)
).scalar() or 0
# Sent gifts (user is buyer) — suppress unneeded relationships
sent_result = await db.execute(
select(GuestPurchase)
.options(
selectinload(GuestPurchase.tariff),
selectinload(GuestPurchase.user),
noload(GuestPurchase.buyer),
noload(GuestPurchase.landing),
)
.where(
GuestPurchase.buyer_user_id == user_id,
GuestPurchase.is_gift.is_(True),
)
.order_by(GuestPurchase.created_at.desc())
.limit(200)
)
sent_purchases = sent_result.scalars().all()
# Received gifts (user is recipient) — suppress unneeded relationships
received_result = await db.execute(
select(GuestPurchase)
.options(
selectinload(GuestPurchase.tariff),
selectinload(GuestPurchase.buyer),
noload(GuestPurchase.user),
noload(GuestPurchase.landing),
)
.where(
GuestPurchase.user_id == user_id,
GuestPurchase.is_gift.is_(True),
)
.order_by(GuestPurchase.created_at.desc())
.limit(200)
)
received_purchases = received_result.scalars().all()
sent_items = [_build_gift_item(p, receiver=p.user) for p in sent_purchases]
received_items = [_build_gift_item(p, buyer=p.buyer) for p in received_purchases]
return AdminUserGiftsResponse(
sent=sent_items,
received=received_items,
sent_total=sent_total,
received_total=received_total,
)
def _build_gift_item(
p: GuestPurchase,
receiver: User | None = None,
buyer: User | None = None,
) -> AdminUserGiftItem:
"""Build an admin gift item from a GuestPurchase."""
tariff_name = p.tariff.name if p.tariff else None
device_limit = p.tariff.device_limit if p.tariff else 1
return AdminUserGiftItem(
id=p.id,
token=p.token[:12],
status=p.status,
tariff_name=tariff_name,
period_days=p.period_days,
device_limit=device_limit,
amount_kopeks=p.amount_kopeks,
payment_method=p.payment_method,
gift_recipient_type=p.gift_recipient_type,
gift_recipient_value=p.gift_recipient_value,
gift_message=p.gift_message,
buyer_user_id=p.buyer_user_id,
buyer_username=buyer.username if buyer else None,
buyer_full_name=buyer.full_name if buyer else None,
receiver_user_id=p.user_id,
receiver_username=receiver.username if receiver else None,
receiver_full_name=receiver.full_name if receiver else None,
created_at=p.created_at,
paid_at=p.paid_at,
delivered_at=p.delivered_at,
)
+281 -14
View File
@@ -5,7 +5,7 @@ import hashlib
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,6 +16,7 @@ from app.database.crud.campaign import (
get_campaign_registration_by_user,
)
from app.database.crud.rbac import UserRoleCRUD
from app.database.crud.system_setting import get_setting_value
from app.database.crud.user import (
clear_email_change_pending,
create_user,
@@ -31,6 +32,7 @@ from app.database.models import CabinetRefreshToken, User
from app.services.campaign_service import AdvertisingCampaignService
from app.services.disposable_email_service import disposable_email_service
from app.services.referral_service import process_referral_registration
from app.utils.cache import RateLimitCache, TokenReplayCache
from app.utils.timezone import panel_datetime_to_utc
from ..auth import (
@@ -40,6 +42,7 @@ from ..auth import (
hash_password,
validate_telegram_init_data,
validate_telegram_login_widget,
validate_telegram_oidc_token,
verify_password,
)
from ..auth.email_verification import (
@@ -53,8 +56,10 @@ from ..auth.email_verification import (
)
from ..auth.jwt_handler import get_refresh_token_expires_at
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..ip_utils import get_client_ip
from ..schemas.auth import (
AuthResponse,
AutoLoginRequest,
CampaignBonusInfo,
EmailChangeRequest,
EmailChangeResponse,
@@ -68,6 +73,7 @@ from ..schemas.auth import (
RefreshTokenRequest,
RegisterResponse,
TelegramAuthRequest,
TelegramOIDCAuthRequest,
TelegramWidgetAuthRequest,
TokenResponse,
UserResponse,
@@ -182,7 +188,12 @@ async def _process_campaign_bonus(
user.referred_by_id = campaign.partner_user_id
await db.flush()
try:
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=None)
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
logger.info(
'Referral set from campaign partner',
user_id=user.id,
@@ -236,7 +247,12 @@ async def _process_referral_code(
return
user.referred_by_id = referrer.id
await db.flush()
await process_referral_registration(db, user.id, referrer.id, bot=None)
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
await process_referral_registration(db, user.id, referrer.id, bot=bot)
logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code)
except Exception as e:
logger.error('Failed to process referral code', error=e, referral_code=referral_code)
@@ -302,7 +318,7 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
connected_squads = [s.get('uuid', '') for s in (panel_user.active_internal_squads or []) if s.get('uuid')]
# Device limit from panel
device_limit = panel_user.hwid_device_limit or 1
device_limit = panel_user.hwid_device_limit or 0
# Determine status — expire_at is now naive UTC
current_time = datetime.now(UTC)
@@ -368,6 +384,7 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
@router.post('/telegram', response_model=AuthResponse)
async def auth_telegram(
request: TelegramAuthRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -376,6 +393,13 @@ async def auth_telegram(
This endpoint validates the initData from Telegram WebApp and returns
JWT tokens for authenticated access.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'telegram_initdata', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
user_data = validate_telegram_init_data(request.init_data)
if not user_data:
@@ -466,6 +490,7 @@ async def auth_telegram(
@router.post('/telegram/widget', response_model=AuthResponse)
async def auth_telegram_widget(
request: TelegramWidgetAuthRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -474,6 +499,15 @@ async def auth_telegram_widget(
This endpoint validates data from Telegram Login Widget and returns
JWT tokens for authenticated access.
"""
# Rate limit
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'telegram_widget', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
widget_data = request.model_dump(exclude={'campaign_slug', 'referral_code'})
if not validate_telegram_login_widget(widget_data):
@@ -541,6 +575,133 @@ async def auth_telegram_widget(
return response
@router.post('/telegram/oidc', response_model=AuthResponse)
async def auth_telegram_oidc(
request: TelegramOIDCAuthRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Authenticate using Telegram OIDC id_token (popup flow).
The frontend uses Telegram.Login.init() popup which returns an id_token.
We validate it via JWKS and create/login the user.
"""
# Rate limit
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'telegram_oidc', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
# Check OIDC enabled from DB first, fallback to env
oidc_enabled_val = await get_setting_value(db, 'TELEGRAM_OIDC_ENABLED')
oidc_client_id_val = await get_setting_value(db, 'TELEGRAM_OIDC_CLIENT_ID')
oidc_client_id = oidc_client_id_val or settings.TELEGRAM_OIDC_CLIENT_ID
oidc_enabled = (
oidc_enabled_val.lower() == 'true' if oidc_enabled_val is not None else settings.TELEGRAM_OIDC_ENABLED
) and bool(oidc_client_id)
if not oidc_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Telegram OIDC is not configured',
)
claims = await validate_telegram_oidc_token(
request.id_token,
oidc_client_id,
)
if not claims:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid or expired Telegram OIDC token',
)
# Replay detection: reject if this exact token was already used
token_hash = hashlib.sha256(request.id_token.encode()).hexdigest()
token_ttl = max(int(claims.get('exp', 0) - datetime.now(UTC).timestamp()), 60)
if await TokenReplayCache.is_token_replayed(token_hash, ttl=min(token_ttl, 600)):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid or expired Telegram OIDC token',
)
# Extract user info from OIDC claims
try:
telegram_id = int(claims.get('id', claims.get('sub', 0)))
except (ValueError, TypeError) as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid user ID in OIDC claims',
) from e
if not telegram_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Missing user ID in OIDC claims',
)
first_name = claims.get('name', claims.get('given_name', ''))
username = claims.get('preferred_username')
last_name = claims.get('family_name')
language = claims.get('locale', 'ru')[:2] if claims.get('locale') else 'ru'
user = await get_user_by_telegram_id(db, telegram_id)
# Resolve referral code for new users
referrer_id = None
if request.referral_code and not user:
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
except (ValueError, LookupError) as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=str(e))
if not user:
logger.info('Creating new user from cabinet OIDC', telegram_id=telegram_id, username=username)
user = await create_user(
db=db,
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
language=language,
referred_by_id=referrer_id,
)
logger.info('User created successfully', user_id=user.id, telegram_id=user.telegram_id)
if user.status != 'active':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User account is not active',
)
# Update user info from OIDC claims
if username and username != user.username:
user.username = username
if first_name and first_name != user.first_name:
user.first_name = first_name
if last_name is not None and last_name != user.last_name:
user.last_name = last_name
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
await _process_referral_code(db, user, request.referral_code)
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
response.user = _user_to_response(user)
return response
@router.post('/email/register')
async def register_email(
request: EmailRegisterRequest,
@@ -613,7 +774,7 @@ async def register_email(
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
custom_subject, custom_body = override or (None, None)
await asyncio.to_thread(
email_service.send_verification_email,
@@ -637,6 +798,7 @@ async def register_email(
@router.post('/email/register/standalone', response_model=RegisterResponse)
async def register_email_standalone(
request: EmailRegisterStandaloneRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -649,6 +811,13 @@ async def register_email_standalone(
If TEST_EMAIL is configured, test email accounts are auto-verified.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'email_register', limit=5, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
# Check if this is a test email registration
is_test_email = settings.is_test_email(request.email)
@@ -742,7 +911,7 @@ async def register_email_standalone(
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
custom_subject, custom_body = override or (None, None)
await asyncio.to_thread(
email_service.send_verification_email,
@@ -758,7 +927,12 @@ async def register_email_standalone(
# Обработать реферальную регистрацию (если есть реферер)
if referrer:
try:
await process_referral_registration(db, user.id, referrer.id, bot=None)
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
await process_referral_registration(db, user.id, referrer.id, bot=bot)
logger.info(
'Processed referral registration: user_id=, referrer_id', user_id=user.id, referrer_id=referrer.id
)
@@ -779,9 +953,17 @@ async def register_email_standalone(
@router.post('/email/verify', response_model=AuthResponse)
async def verify_email(
request: EmailVerifyRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Verify email with token and return auth tokens."""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'email_verify', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
# Find user with this token
result = await db.execute(select(User).where(User.email_verification_token == request.token))
user = result.scalar_one_or_none()
@@ -867,7 +1049,7 @@ async def resend_verification(
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
custom_subject, custom_body = override or (None, None)
await asyncio.to_thread(
email_service.send_verification_email,
@@ -896,12 +1078,21 @@ async def resend_verification(
@router.post('/email/login', response_model=AuthResponse)
async def login_email(
request: EmailLoginRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Login with email and password.
Test email accounts (configured via TEST_EMAIL) bypass email verification.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'email_login', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
# Check if this is a test email login
is_test_email = settings.is_test_email(request.email)
@@ -985,11 +1176,11 @@ async def refresh_token(
try:
user_id = int(payload.get('sub'))
except (TypeError, ValueError):
except (TypeError, ValueError) as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid token payload',
)
) from e
# Verify token exists in database and is not revoked
token_hash = hashlib.sha256(request.refresh_token.encode()).hexdigest()
@@ -1061,17 +1252,85 @@ async def logout(
return {'message': 'Logged out successfully'}
@router.post('/login/auto', response_model=AuthResponse)
async def auto_login(
request: AutoLoginRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Auto-login using a short-lived JWT from guest purchase success page."""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'auto_login', limit=5, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
payload = get_token_payload(request.token, expected_type='auto_login')
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid or expired auto-login token',
)
try:
user_id = int(payload['sub'])
except (KeyError, ValueError, TypeError) as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid token payload',
) from e
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not found',
)
if user.status != 'active':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Account is deactivated',
)
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
return response
@router.post('/password/forgot')
async def forgot_password(
request: PasswordForgotRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Request password reset."""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'password_forgot', limit=3, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
result = await db.execute(select(User).where(User.email == request.email))
user = result.scalar_one_or_none()
# Always return success to prevent email enumeration
if not user or not user.email_verified:
if not user:
return {'message': 'If the email exists, a password reset link has been sent'}
# Auto-fix guest-created email users who have a password but weren't verified
if not user.email_verified and user.password_hash and user.auth_type == 'email':
user.email_verified = True
user.email_verified_at = datetime.now(UTC)
await db.commit()
if not user.email_verified:
return {'message': 'If the email exists, a password reset link has been sent'}
# Generate reset token
@@ -1097,7 +1356,7 @@ async def forgot_password(
context={'username': user.first_name or '', 'reset_url': full_url, 'expire_hours': str(expire_hours)},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
custom_subject, custom_body = override or (None, None)
await asyncio.to_thread(
email_service.send_password_reset_email,
@@ -1116,9 +1375,17 @@ async def forgot_password(
@router.post('/password/reset')
async def reset_password(
request: PasswordResetRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset password with token."""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'password_reset', limit=5, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
result = await db.execute(select(User).where(User.password_reset_token == request.token))
user = result.scalar_one_or_none()
@@ -1257,7 +1524,7 @@ async def request_email_change(
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
custom_subject, custom_body = override or (None, None)
try:
await asyncio.to_thread(
@@ -1312,7 +1579,7 @@ async def request_email_change(
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
custom_subject, custom_body = override or (None, None)
await asyncio.to_thread(
email_service.send_email_change_code,
+168 -11
View File
@@ -14,6 +14,10 @@ from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.saved_payment_method import (
deactivate_payment_method,
get_active_payment_methods_by_user,
)
from app.database.crud.user import get_user_by_id
from app.database.models import PaymentMethod, Transaction, User
from app.services.payment_method_config_service import get_enabled_methods_for_user
@@ -35,6 +39,8 @@ from ..schemas.balance import (
PaymentMethodResponse,
PendingPaymentListResponse,
PendingPaymentResponse,
SavedCardResponse,
SavedCardsListResponse,
StarsInvoiceRequest,
StarsInvoiceResponse,
TopUpRequest,
@@ -102,8 +108,8 @@ async def get_transactions(
for t in transactions:
# Determine sign based on transaction type
# Credits (positive): DEPOSIT, REFERRAL_REWARD, REFUND, POLL_REWARD
# Debits (negative): SUBSCRIPTION_PAYMENT, WITHDRAWAL
is_debit = t.type in ['subscription_payment', 'withdrawal']
# Debits (negative): SUBSCRIPTION_PAYMENT, WITHDRAWAL, GIFT_PAYMENT
is_debit = t.type in ['subscription_payment', 'withdrawal', 'gift_payment']
amount_kopeks = -abs(t.amount_kopeks) if is_debit else abs(t.amount_kopeks)
items.append(
@@ -198,7 +204,7 @@ async def get_payment_methods(
'description': description,
}
)
options = formatted_options if formatted_options else None
options = formatted_options or None
methods.append(
PaymentMethodResponse(
@@ -244,13 +250,16 @@ async def create_stars_invoice(
detail='Maximum amount is 10,000.00 RUB',
)
# Calculate Stars amount
# Calculate Stars amount and normalize kopeks to match exact star value
try:
amount_rubles = request.amount_kopeks / 100
stars_amount = settings.rubles_to_stars(amount_rubles)
if stars_amount <= 0:
stars_amount = 1
# Normalize kopeks so credited amount = stars * rate (no rounding mismatch)
normalized_kopeks = round(stars_amount * settings.get_stars_rate() * 100)
except Exception as e:
logger.error('Error calculating Stars amount', error=e)
raise HTTPException(
@@ -259,7 +268,7 @@ async def create_stars_invoice(
)
# Create payload for tracking payment
payload = f'balance_topup_{user.id}_{request.amount_kopeks}_{int(time.time())}'
payload = f'balance_topup_{user.id}_{normalized_kopeks}_{int(time.time())}'
# Create invoice through Telegram Bot API
try:
@@ -271,7 +280,7 @@ async def create_stars_invoice(
api_url,
json={
'title': 'Пополнение баланса VPN',
'description': f'Пополнение баланса на {amount_rubles:.2f} ₽ ({stars_amount} ⭐)',
'description': f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)',
'payload': payload,
'provider_token': '', # Empty for Stars
'currency': 'XTR',
@@ -299,7 +308,7 @@ async def create_stars_invoice(
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
amount_kopeks=request.amount_kopeks,
amount_kopeks=normalized_kopeks,
)
except httpx.HTTPError as e:
@@ -349,6 +358,9 @@ async def create_topup(
amount_rubles = request.amount_kopeks / 100
payment_url = None
payment_id = None
cabinet_return_url = f'{settings.CABINET_URL.rstrip("/")}/balance/top-up/result?method={request.payment_method}'
cabinet_success_url = f'{cabinet_return_url}&status=success'
cabinet_failed_url = f'{cabinet_return_url}&status=failed'
try:
if request.payment_method == 'yookassa':
@@ -373,6 +385,7 @@ async def create_topup(
amount_kopeks=request.amount_kopeks,
description=description,
metadata=yookassa_metadata,
return_url=cabinet_return_url,
)
else:
result = await payment_service.create_yookassa_payment(
@@ -381,11 +394,12 @@ async def create_topup(
amount_kopeks=request.amount_kopeks,
description=description,
metadata=yookassa_metadata,
return_url=cabinet_return_url,
)
if result:
payment_url = result.get('confirmation_url')
payment_id = result.get('yookassa_payment_id')
payment_id = str(result.get('local_payment_id') or result.get('yookassa_payment_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -490,6 +504,8 @@ async def create_topup(
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_code=method_code,
return_url=cabinet_success_url,
failed_url=cabinet_failed_url,
)
if result and result.get('redirect_url'):
@@ -515,6 +531,8 @@ async def create_topup(
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_return_url,
success_url=cabinet_success_url,
)
if result and result.get('payment_url'):
@@ -562,7 +580,6 @@ async def create_topup(
option = (request.payment_option or '').strip().lower()
if option not in {'card', 'sbp'}:
option = 'sbp'
provider_method = 'card' if option == 'card' else 'sbp'
payment_service = PaymentService()
result = await payment_service.create_pal24_payment(
@@ -571,7 +588,6 @@ async def create_topup(
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method=provider_method,
)
if result:
@@ -612,6 +628,8 @@ async def create_topup(
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_success_url,
failed_url=cabinet_failed_url,
)
if result and result.get('payment_url'):
@@ -638,6 +656,8 @@ async def create_topup(
description=settings.get_balance_payment_description(request.amount_kopeks),
telegram_id=user.telegram_id,
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_success_url,
failed_url=cabinet_failed_url,
)
if result and result.get('payment_url'):
@@ -874,7 +894,7 @@ def _is_checkable(record: PendingPayment) -> bool:
if record.method == PaymentMethod.YOOKASSA:
return status in {'pending', 'waiting_for_capture'}
if record.method == PaymentMethod.CRYPTOBOT:
return status in {'active'}
return status == 'active'
if record.method == PaymentMethod.CLOUDPAYMENTS:
return status in {'pending', 'authorized'}
if record.method == PaymentMethod.FREEKASSA:
@@ -965,6 +985,91 @@ async def get_pending_payments(
)
@router.get('/pending-payments/{method}/latest', response_model=PendingPaymentResponse)
async def get_latest_payment_by_method(
method: str,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's most recent payment for a given method (any status, not just pending)."""
try:
payment_method = PaymentMethod(method)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid payment method: {method}',
)
from datetime import UTC, datetime, timedelta
from sqlalchemy.orm import selectinload
from app.database.models import (
CloudPaymentsPayment,
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
Pal24Payment,
PlategaPayment,
WataPayment,
YooKassaPayment,
)
model_map: dict[PaymentMethod, type] = {
PaymentMethod.YOOKASSA: YooKassaPayment,
PaymentMethod.CRYPTOBOT: CryptoBotPayment,
PaymentMethod.HELEKET: HeleketPayment,
PaymentMethod.MULENPAY: MulenPayPayment,
PaymentMethod.PAL24: Pal24Payment,
PaymentMethod.WATA: WataPayment,
PaymentMethod.PLATEGA: PlategaPayment,
PaymentMethod.CLOUDPAYMENTS: CloudPaymentsPayment,
PaymentMethod.FREEKASSA: FreekassaPayment,
PaymentMethod.KASSA_AI: KassaAiPayment,
}
model = model_map.get(payment_method)
if not model:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Unsupported payment method: {method}',
)
cutoff = datetime.now(UTC) - timedelta(hours=1)
stmt = (
select(model)
.options(selectinload(model.user))
.where(model.user_id == user.id, model.created_at >= cutoff)
.order_by(desc(model.created_at))
.limit(1)
)
result = await db.execute(stmt)
payment = result.scalars().first()
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No recent payments found',
)
record = PendingPayment(
local_id=payment.id,
method=payment_method,
identifier=str(getattr(payment, 'correlation_id', None) or payment.id),
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
created_at=payment.created_at,
expires_at=getattr(payment, 'expires_at', None),
user=payment.user,
payment=payment,
)
return _record_to_response(record)
@router.get('/pending-payments/{method}/{payment_id}', response_model=PendingPaymentResponse)
async def get_pending_payment_details(
method: str,
@@ -1075,3 +1180,55 @@ async def check_payment_status(
old_status=old_status,
new_status=updated.status,
)
@router.get('/saved-cards', response_model=SavedCardsListResponse)
async def get_saved_cards(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's saved payment methods (cards) for recurrent payments."""
recurrent_enabled = settings.YOOKASSA_RECURRENT_ENABLED
if not recurrent_enabled:
return SavedCardsListResponse(cards=[], recurrent_enabled=False)
methods = await get_active_payment_methods_by_user(db, user.id)
cards = [
SavedCardResponse(
id=m.id,
method_type=m.method_type,
card_last4=m.card_last4,
card_type=m.card_type,
title=m.title,
created_at=m.created_at,
)
for m in methods
]
return SavedCardsListResponse(cards=cards, recurrent_enabled=True)
@router.delete('/saved-cards/{card_id}', status_code=status.HTTP_200_OK)
async def delete_saved_card(
card_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unlink (deactivate) a saved payment method."""
if not settings.YOOKASSA_RECURRENT_ENABLED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Recurrent payments are not enabled',
)
success = await deactivate_payment_method(db, card_id, user.id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Saved card not found',
)
return {'success': True, 'message': 'Card unlinked successfully'}
+110 -14
View File
@@ -1,5 +1,6 @@
"""Branding routes for cabinet - logo, project name, and theme colors management."""
import asyncio
import json
import os
from pathlib import Path
@@ -13,6 +14,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.system_setting import get_setting_value
from app.database.models import SystemSetting, User
from ..dependencies import get_cabinet_db, require_permission
@@ -38,7 +40,14 @@ YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeri
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
GIFT_ENABLED_KEY = 'CABINET_GIFT_ENABLED' # Stores "true" or "false"
ANIMATION_CONFIG_KEY = 'CABINET_ANIMATION_CONFIG' # Stores JSON with animation config
TELEGRAM_WIDGET_SIZE_KEY = 'TELEGRAM_WIDGET_SIZE'
TELEGRAM_WIDGET_RADIUS_KEY = 'TELEGRAM_WIDGET_RADIUS'
TELEGRAM_WIDGET_USERPIC_KEY = 'TELEGRAM_WIDGET_USERPIC'
TELEGRAM_WIDGET_REQUEST_ACCESS_KEY = 'TELEGRAM_WIDGET_REQUEST_ACCESS'
TELEGRAM_OIDC_ENABLED_KEY = 'TELEGRAM_OIDC_ENABLED'
TELEGRAM_OIDC_CLIENT_ID_KEY = 'TELEGRAM_OIDC_CLIENT_ID'
# Default animation config
DEFAULT_ANIMATION_CONFIG = {
@@ -243,6 +252,20 @@ class EmailAuthEnabledUpdate(BaseModel):
enabled: bool
class TelegramWidgetConfigResponse(BaseModel):
"""Public Telegram Login Widget configuration."""
bot_username: str
size: Literal['large', 'medium', 'small'] = 'large'
radius: int = Field(default=8, ge=0, le=20)
userpic: bool = True
request_access: bool = True
# OIDC fields (frontend decides which flow to use)
oidc_enabled: bool = False
oidc_client_id: str = ''
class LiteModeEnabledResponse(BaseModel):
"""Lite mode enabled setting."""
@@ -255,6 +278,18 @@ class LiteModeEnabledUpdate(BaseModel):
enabled: bool
class GiftEnabledResponse(BaseModel):
"""Gift feature enabled setting."""
enabled: bool = False
class GiftEnabledUpdate(BaseModel):
"""Request to update gift feature setting."""
enabled: bool
class AnalyticsCountersResponse(BaseModel):
"""Analytics counter settings."""
@@ -296,13 +331,6 @@ def ensure_branding_dir():
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
async def get_setting_value(db: AsyncSession, key: str) -> str | None:
"""Get a setting value from database."""
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def set_setting_value(db: AsyncSession, key: str, value: str):
"""Set a setting value in database."""
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
@@ -374,7 +402,7 @@ async def get_logo():
"""
logo_path = get_logo_path()
if logo_path is None or not logo_path.exists():
if logo_path is None or not await asyncio.to_thread(logo_path.exists):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='No custom logo set')
# Determine media type from file extension
@@ -443,7 +471,7 @@ async def upload_logo(
)
# Ensure directory exists
ensure_branding_dir()
await asyncio.to_thread(ensure_branding_dir)
# Determine file extension from content type
ext_map = {
@@ -456,12 +484,12 @@ async def upload_logo(
extension = ext_map.get(file.content_type, '.png')
# Remove old logo files with any extension
for old_file in BRANDING_DIR.glob('logo.*'):
old_file.unlink()
for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))):
await asyncio.to_thread(old_file.unlink)
# Save new logo
logo_path = BRANDING_DIR / f'logo{extension}'
logo_path.write_bytes(content)
await asyncio.to_thread(logo_path.write_bytes, content)
# Mark that we have a custom logo
await set_setting_value(db, BRANDING_LOGO_KEY, 'custom')
@@ -490,8 +518,8 @@ async def delete_logo(
):
"""Delete custom logo and revert to letter. Admin only."""
# Remove logo files
for old_file in BRANDING_DIR.glob('logo.*'):
old_file.unlink()
for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))):
await asyncio.to_thread(old_file.unlink)
# Update setting
await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
@@ -830,6 +858,47 @@ async def update_email_auth_enabled(
return EmailAuthEnabledResponse(enabled=payload.enabled)
# ============ Telegram Widget Config Routes ============
@router.get('/telegram-widget', response_model=TelegramWidgetConfigResponse)
async def get_telegram_widget_config(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get Telegram Login Widget configuration.
This is a public endpoint - no authentication required.
Returns widget display settings and bot username for the login page.
"""
bot_username = settings.BOT_USERNAME or ''
size_val = await get_setting_value(db, TELEGRAM_WIDGET_SIZE_KEY)
radius_val = await get_setting_value(db, TELEGRAM_WIDGET_RADIUS_KEY)
userpic_val = await get_setting_value(db, TELEGRAM_WIDGET_USERPIC_KEY)
request_access_val = await get_setting_value(db, TELEGRAM_WIDGET_REQUEST_ACCESS_KEY)
oidc_enabled_val = await get_setting_value(db, TELEGRAM_OIDC_ENABLED_KEY)
oidc_client_id_val = await get_setting_value(db, TELEGRAM_OIDC_CLIENT_ID_KEY)
oidc_client_id = oidc_client_id_val or settings.TELEGRAM_OIDC_CLIENT_ID
oidc_enabled = (
oidc_enabled_val.lower() == 'true' if oidc_enabled_val is not None else settings.TELEGRAM_OIDC_ENABLED
) and bool(oidc_client_id)
return TelegramWidgetConfigResponse(
bot_username=bot_username,
size=size_val if size_val in ('large', 'medium', 'small') else settings.TELEGRAM_WIDGET_SIZE,
radius=max(0, min(int(radius_val), 20))
if radius_val and radius_val.isdigit()
else settings.TELEGRAM_WIDGET_RADIUS,
userpic=userpic_val.lower() == 'true' if userpic_val is not None else settings.TELEGRAM_WIDGET_USERPIC,
request_access=request_access_val.lower() == 'true'
if request_access_val is not None
else settings.TELEGRAM_WIDGET_REQUEST_ACCESS,
oidc_enabled=oidc_enabled,
oidc_client_id=oidc_client_id if oidc_enabled else '',
)
# ============ Analytics Counters Routes ============
@@ -928,3 +997,30 @@ async def update_lite_mode_enabled(
logger.info('Admin set lite mode enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return LiteModeEnabledResponse(enabled=payload.enabled)
# ============ Gift Feature Routes ============
@router.get('/gift-enabled', response_model=GiftEnabledResponse)
async def get_gift_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get gift feature enabled setting. Public endpoint."""
value = await get_setting_value(db, GIFT_ENABLED_KEY)
if value is not None:
enabled = value.lower() == 'true'
return GiftEnabledResponse(enabled=enabled)
return GiftEnabledResponse(enabled=False)
@router.patch('/gift-enabled', response_model=GiftEnabledResponse)
async def update_gift_enabled(
payload: GiftEnabledUpdate,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update gift feature enabled setting. Admin only."""
await set_setting_value(db, GIFT_ENABLED_KEY, str(payload.enabled).lower())
logger.info('Admin set gift enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return GiftEnabledResponse(enabled=payload.enabled)
+4
View File
@@ -86,6 +86,7 @@ def _user_allowed(subscription) -> bool:
return subscription.status in {
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
SubscriptionStatus.LIMITED.value,
}
@@ -121,6 +122,9 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
if not user:
return 'Error: user not found'
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
user.balance_kopeks += int(round(amount * 100))
await db.commit()
await db.refresh(user)
+816
View File
@@ -0,0 +1,816 @@
"""Gift subscription routes for cabinet."""
import asyncio
import re
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.system_setting import get_setting_value
from app.database.crud.tariff import get_tariff_by_id
from app.database.crud.transaction import create_transaction, emit_transaction_side_effects
from app.database.crud.user import subtract_user_balance
from app.database.models import (
GuestPurchase,
GuestPurchaseStatus,
PaymentMethod,
Tariff,
TransactionType,
User,
UserPromoGroup,
)
from app.services.guest_purchase_service import (
GuestPurchaseError,
create_purchase,
fulfill_purchase,
)
from app.services.payment_method_config_service import get_enabled_methods_for_user
from app.utils.cache import RateLimitCache
from app.utils.promo_offer import get_user_active_promo_discount_percent
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.gift import (
ActivateGiftRequest,
ActivateGiftResponse,
GiftConfigPaymentMethod,
GiftConfigResponse,
GiftConfigSubOption,
GiftConfigTariff,
GiftConfigTariffPeriod,
GiftPurchaseRequest,
GiftPurchaseResponse,
GiftPurchaseStatusResponse,
PendingGiftResponse,
ReceivedGiftResponse,
SentGiftResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/gift', tags=['Cabinet Gift'])
GIFT_ENABLED_KEY = 'CABINET_GIFT_ENABLED'
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{4,31}$')
async def _is_gift_enabled(db: AsyncSession) -> bool:
"""Check if the gift feature is enabled via system settings."""
value = await get_setting_value(db, GIFT_ENABLED_KEY)
if value is not None:
return value.lower() == 'true'
return False
@router.get('/config', response_model=GiftConfigResponse)
async def get_gift_config(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get gift subscription configuration: tariffs, payment methods, balance."""
enabled = await _is_gift_enabled(db)
if not enabled:
return GiftConfigResponse(
is_enabled=False,
balance_kopeks=user.balance_kopeks,
)
# Load active tariffs visible in gift section
result = await db.execute(
select(Tariff)
.where(Tariff.is_active.is_(True), Tariff.show_in_gift.is_(True))
.order_by(Tariff.display_order, Tariff.id)
)
tariffs_db = result.scalars().all()
# Get user's promo group for discount calculation
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group is None:
promo_group = getattr(user, 'promo_group', None)
promo_group_name = promo_group.name if promo_group else None
# Get active promo offer discount
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
tariffs: list[GiftConfigTariff] = []
for tariff in tariffs_db:
period_days_list = tariff.get_available_periods()
periods: list[GiftConfigTariffPeriod] = []
for days in period_days_list:
base_price = tariff.get_price_for_period(days)
if base_price is None:
continue
original_price = base_price
price = base_price
# Apply promo group discount
promo_group_discount = 0
if promo_group:
promo_group_discount = promo_group.get_discount_percent('period', days)
if promo_group_discount > 0:
price = int(price * (100 - promo_group_discount) / 100)
# Apply active promo offer discount (stacks on top)
if promo_offer_discount_percent > 0:
price = price - price * promo_offer_discount_percent // 100
# Ensure minimum price of 1 kopek after all discounts
price = max(1, price)
# Calculate combined discount percent
combined_discount = 0
if original_price > 0 and original_price != price:
combined_discount = int((original_price - price) * 100 / original_price)
periods.append(
GiftConfigTariffPeriod(
days=days,
price_kopeks=price,
price_label=settings.format_price(price),
original_price_kopeks=original_price if combined_discount > 0 else None,
discount_percent=combined_discount if combined_discount > 0 else None,
)
)
if not periods:
continue
tariffs.append(
GiftConfigTariff(
id=tariff.id,
name=tariff.name,
description=tariff.description,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
periods=periods,
)
)
# Load payment methods available for this user
enabled_methods = await get_enabled_methods_for_user(db, user=user)
payment_methods: list[GiftConfigPaymentMethod] = []
for method_data in enabled_methods:
sub_options = None
raw_options = method_data.get('options')
if raw_options:
sub_options = [GiftConfigSubOption(id=opt['id'], name=opt.get('name', opt['id'])) for opt in raw_options]
payment_methods.append(
GiftConfigPaymentMethod(
method_id=method_data['id'],
display_name=method_data['name'],
min_amount_kopeks=method_data.get('min_amount_kopeks'),
max_amount_kopeks=method_data.get('max_amount_kopeks'),
sub_options=sub_options,
)
)
return GiftConfigResponse(
is_enabled=True,
tariffs=tariffs,
payment_methods=payment_methods,
balance_kopeks=user.balance_kopeks,
currency_symbol=getattr(settings, 'CURRENCY_SYMBOL', '\u20bd'),
promo_group_name=promo_group_name,
active_discount_percent=promo_offer_discount_percent if promo_offer_discount_percent > 0 else None,
active_discount_expires_at=(
getattr(user, 'promo_offer_discount_expires_at', None) if promo_offer_discount_percent > 0 else None
),
)
@router.post('/purchase', response_model=GiftPurchaseResponse)
async def create_gift_purchase(
body: GiftPurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a gift subscription purchase from the cabinet."""
enabled = await _is_gift_enabled(db)
if not enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Gift feature is not enabled',
)
# Rate limit: 5 gift purchases per 60 seconds per user
is_limited = await RateLimitCache.is_rate_limited(user.id, 'gift_purchase', limit=5, window=60)
if is_limited:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
# Check if user has purchase restrictions
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Purchases are restricted for this account',
)
# Recipient is optional — when omitted, buyer gets a code to share manually
has_recipient = bool(body.recipient_type and body.recipient_value)
if has_recipient:
# Validate recipient format
if body.recipient_type == 'email' and not _EMAIL_RE.match(body.recipient_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid email format',
)
if body.recipient_type == 'telegram' and not _TELEGRAM_RE.match(body.recipient_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid Telegram username format',
)
# Prevent self-gift
if body.recipient_type == 'telegram':
normalized_recipient = body.recipient_value.lstrip('@').lower()
if user.username and user.username.lower() == normalized_recipient:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot gift to yourself',
)
elif body.recipient_type == 'email':
if user.email and user.email.lower() == body.recipient_value.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot gift to yourself',
)
# Find tariff and validate period
tariff = await get_tariff_by_id(db, body.tariff_id)
if tariff is None or not tariff.is_active or not tariff.show_in_gift:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found or inactive',
)
price_kopeks = tariff.get_price_for_period(body.period_days)
if price_kopeks is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Price is not configured for this period',
)
# Lock user row to prevent concurrent promo offer double-spend
locked_result = await db.execute(
select(User)
.options(
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.promo_group),
)
.where(User.id == user.id)
.with_for_update()
.execution_options(populate_existing=True)
)
user = locked_result.scalar_one()
# Apply promo group discount
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group is None:
promo_group = getattr(user, 'promo_group', None)
if promo_group:
discount_percent = promo_group.get_discount_percent('period', body.period_days)
if discount_percent > 0:
price_kopeks = int(price_kopeks * (100 - discount_percent) / 100)
# Apply active promo offer discount (stacks)
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
if promo_offer_discount_percent > 0:
price_kopeks = price_kopeks - price_kopeks * promo_offer_discount_percent // 100
# Ensure minimum price of 1 kopek after all discounts
price_kopeks = max(1, price_kopeks)
# Determine buyer contact info
if user.email:
buyer_contact_type = 'email'
buyer_contact_value = user.email
elif user.username:
buyer_contact_type = 'telegram'
buyer_contact_value = f'@{user.username}'
else:
buyer_contact_type = 'telegram'
buyer_contact_value = f'id:{user.telegram_id or user.id}'
# Pre-check: try to resolve Telegram username — DB first, then Bot API.
# Only relevant when a recipient is explicitly specified.
recipient_warning: str | None = None
pre_resolved_telegram_id: int | None = None
if has_recipient and body.recipient_type == 'telegram':
tg_username = body.recipient_value.lstrip('@')
normalized_username = tg_username.lower()
# 1) Check local DB — user may already be registered in the bot
db_result = await db.execute(
select(User.telegram_id).where(
func.lower(User.username) == normalized_username,
User.telegram_id.isnot(None),
)
)
db_telegram_id = db_result.scalar_one_or_none()
if db_telegram_id is not None:
pre_resolved_telegram_id = db_telegram_id
else:
# 2) Fall back to Bot API (works for public usernames the bot has seen)
try:
from aiogram import Bot
async with Bot(token=settings.BOT_TOKEN) as bot:
chat = await asyncio.wait_for(bot.get_chat(chat_id=f'@{tg_username}'), timeout=5.0)
pre_resolved_telegram_id = chat.id
except Exception:
recipient_warning = 'telegram_unresolvable'
logger.warning(
'Telegram username not resolvable for gift',
username=tg_username,
buyer_id=user.id,
)
# Gateway mode: create payment via external provider
if body.payment_mode == 'gateway':
if not body.payment_method:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='payment_method is required for gateway mode',
)
purchase_kwargs: dict = (
{
'gift_recipient_type': body.recipient_type,
'gift_recipient_value': body.recipient_value,
'gift_message': body.gift_message,
}
if has_recipient
else {
'gift_message': body.gift_message,
}
)
try:
purchase = await create_purchase(
db,
landing=None,
tariff=tariff,
period_days=body.period_days,
amount_kopeks=price_kopeks,
contact_type=buyer_contact_type,
contact_value=buyer_contact_value,
payment_method=body.payment_method,
is_gift=True,
source='cabinet',
buyer_user_id=user.id,
commit=False,
**purchase_kwargs,
)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
# Persist warning so it survives the gateway redirect
if recipient_warning:
purchase.recipient_warning = recipient_warning
# Build return URL for after payment
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
return_url = f'{cabinet_base}/gift/result?token={purchase.token[:12]}'
from app.services.payment_service import PaymentService
# Stars payments need a Bot instance to create invoice links
bot = None
if body.payment_method == 'telegram_stars':
from aiogram import Bot
bot = Bot(token=settings.BOT_TOKEN)
payment_service = PaymentService(bot=bot)
payment_result = await payment_service.create_guest_payment(
db=db,
amount_kopeks=price_kopeks,
payment_method=body.payment_method,
description=f'Gift: {tariff.name} ({body.period_days}d)',
purchase_token=purchase.token,
return_url=return_url,
)
if payment_result is None:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Payment provider is unavailable, please try again later',
)
payment_url = payment_result.get('payment_url')
if not payment_url:
await db.rollback()
logger.error(
'Gift payment created but no payment_url returned',
purchase_token=purchase.token[:5],
provider=payment_result.get('provider'),
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Payment provider returned an invalid response',
)
# Consume promo offer discount before committing gateway purchase
if promo_offer_discount_percent > 0 and getattr(user, 'promo_offer_discount_percent', 0):
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
await db.commit()
await db.refresh(purchase)
return GiftPurchaseResponse(
status='created',
purchase_token=purchase.token[:12],
payment_url=payment_url,
warning=recipient_warning,
)
# Balance mode
if user.balance_kopeks < price_kopeks:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Insufficient balance',
)
# Create purchase record
balance_purchase_kwargs: dict = (
{
'gift_recipient_type': body.recipient_type,
'gift_recipient_value': body.recipient_value,
'gift_message': body.gift_message,
}
if has_recipient
else {
'gift_message': body.gift_message,
}
)
try:
purchase = await create_purchase(
db,
landing=None,
tariff=tariff,
period_days=body.period_days,
amount_kopeks=price_kopeks,
contact_type=buyer_contact_type,
contact_value=buyer_contact_value,
payment_method='balance',
is_gift=True,
source='cabinet',
buyer_user_id=user.id,
commit=False,
**balance_purchase_kwargs,
)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
# Persist warning on purchase record
if recipient_warning:
purchase.recipient_warning = recipient_warning
# Subtract balance (consume promo offer if one was applied)
balance_ok = await subtract_user_balance(
db,
user,
price_kopeks,
description=f'Gift: {tariff.name} ({body.period_days}d)',
create_transaction=False,
consume_promo_offer=promo_offer_discount_percent > 0,
)
if not balance_ok:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Insufficient balance',
)
# Transaction description: include recipient when specified
tx_description = f'Gift: {tariff.name} ({body.period_days}d)'
if has_recipient:
tx_description += f' -> {body.recipient_value}'
# Create transaction record
transaction = await create_transaction(
db,
user_id=user.id,
type=TransactionType.GIFT_PAYMENT,
amount_kopeks=price_kopeks,
description=tx_description,
payment_method=PaymentMethod.BALANCE,
commit=False,
)
# Mark purchase as paid
purchase.status = GuestPurchaseStatus.PAID.value
purchase.paid_at = datetime.now(UTC)
await db.commit()
# Emit deferred side-effects after atomic commit
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=price_kopeks,
user_id=user.id,
type=TransactionType.GIFT_PAYMENT,
payment_method=PaymentMethod.BALANCE,
description=tx_description,
)
# Capture token before fulfill_purchase — session state may change after rollback inside fulfill
purchase_token = purchase.token
# Only fulfill immediately when a specific recipient was provided.
# Code-only gifts (no recipient) stay in PAID status until someone activates via code.
if has_recipient:
try:
await fulfill_purchase(db, purchase_token, pre_resolved_telegram_id=pre_resolved_telegram_id)
except Exception:
logger.exception(
'Gift purchase fulfillment failed (purchase is paid, will retry)',
purchase_id=purchase.id,
)
return GiftPurchaseResponse(
status='ok',
purchase_token=purchase_token[:12],
warning=recipient_warning,
)
@router.get('/pending', response_model=list[PendingGiftResponse])
async def get_pending_gifts(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get pending gift purchases that the current user can activate."""
result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff))
.where(
GuestPurchase.user_id == user.id,
GuestPurchase.is_gift.is_(True),
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
)
.order_by(GuestPurchase.created_at.desc())
.limit(100)
)
purchases = result.scalars().all()
pending: list[PendingGiftResponse] = []
for p in purchases:
# Determine sender display name
sender_display = None
if p.contact_value:
sender_display = p.contact_value
pending.append(
PendingGiftResponse(
token=p.token[:12],
tariff_name=p.tariff.name if p.tariff else None,
period_days=p.period_days,
gift_message=p.gift_message,
sender_display=sender_display,
created_at=p.created_at,
)
)
return pending
@router.get('/purchase/{token}', response_model=GiftPurchaseStatusResponse)
async def get_gift_purchase_status(
token: str,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get the status of a cabinet gift purchase."""
if len(token) >= 64:
token_filter = GuestPurchase.token == token
else:
token_filter = GuestPurchase.token.startswith(token)
result = await db.execute(select(GuestPurchase).options(selectinload(GuestPurchase.tariff)).where(token_filter))
purchase = result.scalars().first()
if purchase is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Purchase not found',
)
# Uniform 404 prevents token existence oracle
if purchase.buyer_user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Purchase not found',
)
tariff_name = purchase.tariff.name if purchase.tariff else None
recipient_contact_value = None
if purchase.gift_recipient_value:
recipient_contact_value = purchase.gift_recipient_value
is_code_only = purchase.is_gift and not purchase.gift_recipient_type
return GiftPurchaseStatusResponse(
status=purchase.status,
is_gift=True,
is_code_only=is_code_only,
purchase_token=purchase.token[:12] if is_code_only else None,
recipient_contact_value=recipient_contact_value,
gift_message=purchase.gift_message,
tariff_name=tariff_name,
period_days=purchase.period_days,
warning=purchase.recipient_warning,
)
@router.get('/sent', response_model=list[SentGiftResponse])
async def get_sent_gifts(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all gifts the current user has sent."""
result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff), selectinload(GuestPurchase.user))
.where(
GuestPurchase.buyer_user_id == user.id,
GuestPurchase.is_gift.is_(True),
)
.order_by(GuestPurchase.created_at.desc())
.limit(100)
)
purchases = result.scalars().all()
sent: list[SentGiftResponse] = []
for p in purchases:
activated_by_username = None
if p.status == GuestPurchaseStatus.DELIVERED.value and p.user and p.user.username:
activated_by_username = f'@{p.user.username}'
sent.append(
SentGiftResponse(
token=p.token[:12],
tariff_name=p.tariff.name if p.tariff else None,
period_days=p.period_days,
device_limit=p.tariff.device_limit if p.tariff else 1,
status=p.status,
gift_recipient_value=p.gift_recipient_value,
gift_message=p.gift_message,
activated_by_username=activated_by_username,
created_at=p.created_at,
)
)
return sent
@router.get('/received', response_model=list[ReceivedGiftResponse])
async def get_received_gifts(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all gifts the current user has received."""
result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff), selectinload(GuestPurchase.buyer))
.where(
GuestPurchase.user_id == user.id,
GuestPurchase.is_gift.is_(True),
)
.order_by(GuestPurchase.created_at.desc())
.limit(100)
)
purchases = result.scalars().all()
received: list[ReceivedGiftResponse] = []
for p in purchases:
sender_display = None
if p.buyer and p.buyer.username:
sender_display = f'@{p.buyer.username}'
elif p.contact_value:
sender_display = p.contact_value
received.append(
ReceivedGiftResponse(
token=p.token[:12],
tariff_name=p.tariff.name if p.tariff else None,
period_days=p.period_days,
device_limit=p.tariff.device_limit if p.tariff else 1,
status=p.status,
sender_display=sender_display,
gift_message=p.gift_message,
created_at=p.created_at,
)
)
return received
@router.post('/activate', response_model=ActivateGiftResponse)
async def activate_gift_by_code(
body: ActivateGiftRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Activate a gift subscription by its code (token)."""
from app.services.guest_purchase_service import activate_purchase as svc_activate
# Bug 2 fix: rate limit activation attempts to prevent brute-force token enumeration
is_limited = await RateLimitCache.is_rate_limited(user.id, 'gift_activate', limit=10, window=60)
if is_limited:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
code = body.code.strip()
if code.upper().startswith('GIFT-'):
code = code[5:]
if len(code) < 8:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Code too short')
# Support both full token and prefix-based lookup (displayed codes are truncated)
if len(code) >= 64:
# Full token — exact match
token_filter = GuestPurchase.token == code
else:
# Prefix match — for short display codes like GIFT-XXXXXXXXXXXX
token_filter = GuestPurchase.token.startswith(code)
result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff))
.where(token_filter, GuestPurchase.is_gift.is_(True))
.with_for_update()
)
purchase = result.scalars().first()
if purchase is None or not purchase.is_gift:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Gift not found',
)
# Bug 1 fix: check ownership BEFORE leaking any status/tariff info
if purchase.user_id is not None and purchase.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Gift not found',
)
# Prevent self-activation: buyer cannot activate their own gift
if purchase.buyer_user_id is not None and purchase.buyer_user_id == user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot activate your own gift',
)
if purchase.status == GuestPurchaseStatus.DELIVERED.value:
return ActivateGiftResponse(
status='activated',
tariff_name=purchase.tariff.name if purchase.tariff else None,
period_days=purchase.period_days,
)
# Code-only gifts are in PAID status; directed gifts are in PENDING_ACTIVATION
activatable_statuses = {
GuestPurchaseStatus.PENDING_ACTIVATION.value,
GuestPurchaseStatus.PAID.value,
}
if purchase.status not in activatable_statuses:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This gift cannot be activated',
)
# For code-only gifts (user_id is None), link the purchase to the activating user
if purchase.user_id is None:
purchase.user_id = user.id
# Transition PAID → PENDING_ACTIVATION so activate_purchase() accepts it
if purchase.status == GuestPurchaseStatus.PAID.value:
purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
await db.flush()
try:
await svc_activate(db, purchase.token, skip_notification=True)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
return ActivateGiftResponse(
status='activated',
tariff_name=purchase.tariff.name if purchase.tariff else None,
period_days=purchase.period_days,
)
+1 -1
View File
@@ -160,7 +160,7 @@ async def get_rules(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get service rules - uses same function as bot."""
requested_lang = language.split('-')[0].lower()
requested_lang = language.split('-', maxsplit=1)[0].lower()
# Use the same function as bot to ensure consistent content
content = await get_current_rules_content(db, requested_lang)
+694
View File
@@ -0,0 +1,694 @@
"""Public landing page routes for guest quick-purchase flow."""
import re
from datetime import UTC, datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.cabinet.dependencies import get_cabinet_db
from app.cabinet.ip_utils import get_client_ip
from app.cabinet.utils.locale import DEFAULT_LOCALE, resolve_locale_text
from app.config import settings
from app.database.crud.landing import get_active_landing_by_slug, get_purchase_by_token
from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage, Tariff
from app.services.guest_purchase_service import (
GuestPurchaseError,
activate_purchase as activate_guest_purchase,
create_purchase,
validate_and_calculate,
)
from app.services.payment_method_config_service import _get_method_defaults
from app.services.payment_service import PaymentService
from app.utils.cache import RateLimitCache
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/landing', tags=['Landing Pages'])
# ============ Schemas ============
class LandingFeature(BaseModel):
icon: str = ''
title: str = ''
description: str = ''
class LandingTariffPeriod(BaseModel):
days: int
label: str
price_kopeks: int
price_label: str
original_price_kopeks: int | None = None # set if discount active
original_price_label: str | None = None
discount_percent: int | None = None # effective discount for this tariff
class LandingTariff(BaseModel):
id: int
name: str
description: str | None = None
traffic_limit_gb: int
device_limit: int
tier_level: int
periods: list[LandingTariffPeriod]
class LandingPaymentMethodSubOption(BaseModel):
id: str
name: str
class LandingPaymentMethod(BaseModel):
method_id: str
display_name: str
description: str | None = None
icon_url: str | None = None
sort_order: int = 0
min_amount_kopeks: int | None = None
max_amount_kopeks: int | None = None
currency: str | None = None
# Enabled sub-options with display labels (e.g. СБП, Карта).
# None or empty means no sub-option selection needed.
sub_options: list[LandingPaymentMethodSubOption] | None = None
class LandingDiscountInfo(BaseModel):
percent: int # default discount
ends_at: str # ISO datetime
badge_text: str | None = None # resolved locale text
class LandingConfigResponse(BaseModel):
slug: str
title: str
subtitle: str | None = None
features: list[LandingFeature]
footer_text: str | None = None
tariffs: list[LandingTariff]
payment_methods: list[LandingPaymentMethod]
gift_enabled: bool
custom_css: str | None = None
meta_title: str | None = None
meta_description: str | None = None
discount: LandingDiscountInfo | None = None # null if no active discount
background_config: dict | None = None
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{4,31}$')
def _validate_contact(contact_type: str, contact_value: str) -> None:
"""Validate contact value matches the declared type format."""
if contact_type == 'email' and not _EMAIL_RE.match(contact_value):
raise ValueError('Invalid email format')
if contact_type == 'telegram' and not _TELEGRAM_RE.match(contact_value):
raise ValueError('Invalid Telegram username format')
class PurchaseRequest(BaseModel):
tariff_id: int
period_days: int
contact_type: str = Field(pattern=r'^(email|telegram)$')
contact_value: str = Field(min_length=1, max_length=255)
payment_method: str = Field(min_length=1, max_length=50, pattern=r'^[a-z0-9_]+$')
is_gift: bool = False
gift_recipient_type: str | None = Field(default=None, pattern=r'^(email|telegram)$')
gift_recipient_value: str | None = Field(default=None, max_length=255)
gift_message: str | None = Field(default=None, max_length=1000)
@model_validator(mode='after')
def validate_contacts(self) -> 'PurchaseRequest':
_validate_contact(self.contact_type, self.contact_value)
if self.is_gift:
if not self.gift_recipient_type or not self.gift_recipient_value:
raise ValueError('Gift recipient type and value are required for gift purchases')
_validate_contact(self.gift_recipient_type, self.gift_recipient_value)
return self
class PurchaseResponse(BaseModel):
purchase_token: str
payment_url: str
class PurchaseStatusResponse(BaseModel):
status: str
subscription_url: str | None = None
subscription_crypto_link: str | None = None
is_gift: bool = False
contact_value: str | None = None
recipient_contact_value: str | None = None
period_days: int | None = None
tariff_name: str | None = None
gift_message: str | None = None
contact_type: str | None = None
cabinet_email: str | None = None
cabinet_password: str | None = None
auto_login_token: str | None = None
recipient_in_bot: bool | None = None
bot_link: str | None = None
# ============ Helpers ============
def _mask_contact(value: str) -> str:
"""Mask contact value to avoid leaking PII in API responses."""
if '@' in value and not value.startswith('@'):
# Email: show first 2 chars + mask + domain
local, domain = value.rsplit('@', 1)
return f'{local[:2]}***@{domain}'
if value.startswith('@'):
# Telegram: show first 3 chars + mask
return f'{value[:3]}***'
return value[:3] + '***'
_SUBSCRIPTION_URL_EXPIRY_HOURS = 24
def _build_purchase_status_response(purchase: GuestPurchase) -> PurchaseStatusResponse:
"""Build a PurchaseStatusResponse from a GuestPurchase record."""
tariff_name = purchase.tariff.name if purchase.tariff else None
within_ttl = False
subscription_url = None
subscription_crypto_link = None
if purchase.delivered_at and purchase.subscription_url and not purchase.is_gift:
age = datetime.now(UTC) - purchase.delivered_at
if age < timedelta(hours=_SUBSCRIPTION_URL_EXPIRY_HOURS):
within_ttl = True
subscription_url = purchase.subscription_url
subscription_crypto_link = purchase.subscription_crypto_link
masked_contact = _mask_contact(purchase.contact_value) if purchase.contact_value else None
recipient_contact_value = None
gift_message = None
if purchase.is_gift:
if purchase.gift_recipient_value:
recipient_contact_value = _mask_contact(purchase.gift_recipient_value)
gift_message = purchase.gift_message
# Determine effective contact type for the recipient
if purchase.is_gift and purchase.gift_recipient_type:
effective_contact_type = purchase.gift_recipient_type
else:
effective_contact_type = purchase.contact_type
# Cabinet credentials for email self-purchases (not gifts)
cabinet_email = None
cabinet_password = None
auto_login_token = None
is_terminal = purchase.status in (GuestPurchaseStatus.DELIVERED.value, GuestPurchaseStatus.PENDING_ACTIVATION.value)
is_email_self_purchase = effective_contact_type == 'email' and not purchase.is_gift
if is_terminal and is_email_self_purchase:
cabinet_email = purchase.contact_value
# For PENDING_ACTIVATION: cap credential exposure at 72h from paid_at
pending_within_ttl = (
purchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value
and purchase.paid_at
and (datetime.now(UTC) - purchase.paid_at) < timedelta(hours=72)
)
if within_ttl or pending_within_ttl:
cabinet_password = purchase.cabinet_password
auto_login_token = purchase.auto_login_token
# For telegram gifts: indicate whether recipient is known to the bot
recipient_in_bot: bool | None = None
bot_link: str | None = None
if purchase.is_gift and effective_contact_type == 'telegram':
recipient_in_bot = purchase.user is not None and purchase.user.telegram_id is not None
if not recipient_in_bot:
bot_username = settings.get_bot_username()
if bot_username:
bot_link = f'https://t.me/{bot_username}'
return PurchaseStatusResponse(
status=purchase.status,
subscription_url=subscription_url,
subscription_crypto_link=subscription_crypto_link,
is_gift=purchase.is_gift,
contact_value=masked_contact,
recipient_contact_value=recipient_contact_value,
period_days=purchase.period_days,
tariff_name=tariff_name,
gift_message=gift_message,
contact_type=effective_contact_type,
cabinet_email=cabinet_email,
cabinet_password=cabinet_password,
auto_login_token=auto_login_token,
recipient_in_bot=recipient_in_bot,
bot_link=bot_link,
)
def _period_label(days: int) -> str:
"""Human-readable label for a period in days."""
if days == 1:
return '1 day'
if days <= 6:
return f'{days} days'
if days == 7:
return '1 week'
if days == 14:
return '2 weeks'
if days == 30:
return '1 month'
if days == 60:
return '2 months'
if days == 90:
return '3 months'
if days == 180:
return '6 months'
if days == 365:
return '1 year'
if days == 456:
return '1 year + 3 mo.'
months = days // 30
remainder = days % 30
if months > 0 and remainder == 0:
return f'{months} mo.'
if months > 0:
return f'{months} mo. + {remainder} d.'
return f'{days} days'
def _get_active_discount(landing: LandingPage, lang: str) -> LandingDiscountInfo | None:
"""Return discount info if currently active, else None."""
if not landing.discount_percent or not landing.discount_starts_at or not landing.discount_ends_at:
return None
now = datetime.now(UTC)
if not (landing.discount_starts_at <= now < landing.discount_ends_at):
return None
badge = resolve_locale_text(landing.discount_badge_text, lang) if landing.discount_badge_text else None
return LandingDiscountInfo(
percent=landing.discount_percent,
ends_at=landing.discount_ends_at.isoformat(),
badge_text=badge or None,
)
async def _load_landing_tariffs(
db: AsyncSession, landing: LandingPage, discount: LandingDiscountInfo | None = None
) -> list[LandingTariff]:
"""Load tariffs for a landing page, filtered by allowed IDs and periods."""
allowed_ids = landing.allowed_tariff_ids or []
if not allowed_ids:
return []
result = await db.execute(
select(Tariff)
.where(Tariff.id.in_(allowed_ids), Tariff.is_active.is_(True))
.order_by(Tariff.display_order, Tariff.id)
)
tariffs = result.scalars().all()
allowed_periods = landing.allowed_periods or {}
landing_tariffs = []
for tariff in tariffs:
# Determine which periods to show
tariff_period_override = allowed_periods.get(str(tariff.id))
if tariff_period_override is not None:
period_days_list = sorted(tariff_period_override)
else:
period_days_list = tariff.get_available_periods()
periods = []
for days in period_days_list:
price = tariff.get_price_for_period(days)
if price is None:
continue
original_price_kopeks = None
original_price_label = None
effective_discount = None
if discount:
# Per-tariff override takes priority (read from landing model, not response DTO)
overrides = landing.discount_overrides or {}
tariff_override = overrides.get(str(tariff.id))
effective_discount = tariff_override if tariff_override is not None else discount.percent
original_price_kopeks = price
original_price_label = settings.format_price(price)
price = max(1, price - (price * effective_discount // 100))
periods.append(
LandingTariffPeriod(
days=days,
label=_period_label(days),
price_kopeks=price,
price_label=settings.format_price(price),
original_price_kopeks=original_price_kopeks,
original_price_label=original_price_label,
discount_percent=effective_discount,
)
)
if not periods:
continue
landing_tariffs.append(
LandingTariff(
id=tariff.id,
name=tariff.name,
description=tariff.description,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
tier_level=tariff.tier_level,
periods=periods,
)
)
return landing_tariffs
# ============ Routes ============
# IMPORTANT: /purchase/{token} must come BEFORE /{slug} to avoid shadowing
# (FastAPI checks routes in definition order; "purchase" would match {slug})
@router.get('/purchase/{token}', response_model=PurchaseStatusResponse)
async def get_purchase_status(
token: str,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get the status of a guest purchase by token.
No authentication required.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'purchase_status', limit=30, window=60, fail_closed=True):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
purchase = await get_purchase_by_token(db, token)
if purchase is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Purchase not found',
)
response = _build_purchase_status_response(purchase)
# Cleanup: null expired credentials from DB
needs_cleanup = False
if purchase.delivered_at and (purchase.cabinet_password or purchase.auto_login_token):
age = datetime.now(UTC) - purchase.delivered_at
if age >= timedelta(hours=_SUBSCRIPTION_URL_EXPIRY_HOURS):
needs_cleanup = True
elif (
purchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value
and purchase.paid_at
and (purchase.cabinet_password or purchase.auto_login_token)
and (datetime.now(UTC) - purchase.paid_at) >= timedelta(hours=72)
):
needs_cleanup = True
if needs_cleanup:
purchase.cabinet_password = None
purchase.auto_login_token = None
await db.commit()
return response
@router.post('/activate/{token}', response_model=PurchaseStatusResponse)
async def activate_purchase(
token: str,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Activate a pending guest purchase, replacing the user's current subscription.
No authentication required (token is the secret).
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'activate_purchase', limit=5, window=60, fail_closed=True):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
try:
purchase = await activate_guest_purchase(db, token)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
return _build_purchase_status_response(purchase)
@router.get('/{slug}', response_model=LandingConfigResponse)
async def get_landing_config(
raw_request: Request,
slug: str = Path(max_length=100),
lang: str = Query(DEFAULT_LOCALE, max_length=5, description='Locale: ru, en, zh, fa'),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get public landing page configuration with tariffs and payment methods.
No authentication required. Pass ``?lang=en`` to get localized text.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_config', limit=60, window=60, fail_closed=True):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
landing = await get_active_landing_by_slug(db, slug)
if landing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Landing page not found',
)
discount = _get_active_discount(landing, lang)
tariffs = await _load_landing_tariffs(db, landing, discount)
# Build payment methods from landing config
raw_methods = landing.payment_methods or []
method_defaults = _get_method_defaults()
payment_methods: list[LandingPaymentMethod] = []
for m in raw_methods:
method_id = m.get('method_id', '')
raw_sub_options = m.get('sub_options') # dict[str, bool] | None
# Resolve sub-options: filter enabled ones and attach display names
resolved_sub_options: list[LandingPaymentMethodSubOption] | None = None
method_def = method_defaults.get(method_id)
available = method_def.get('available_sub_options') if method_def else None
if available:
resolved = []
for opt in available:
opt_id = opt['id']
# If landing has explicit sub_options config, respect it; otherwise all enabled
if raw_sub_options is None or raw_sub_options.get(opt_id, True):
resolved.append(LandingPaymentMethodSubOption(id=opt_id, name=opt['name']))
if resolved:
resolved_sub_options = resolved
payment_methods.append(
LandingPaymentMethod(
method_id=method_id,
display_name=m.get('display_name', ''),
description=m.get('description'),
icon_url=m.get('icon_url'),
sort_order=m.get('sort_order', 0),
min_amount_kopeks=m.get('min_amount_kopeks'),
max_amount_kopeks=m.get('max_amount_kopeks'),
currency=m.get('currency'),
sub_options=resolved_sub_options,
)
)
# Resolve locale dicts to flat strings for the requested language
features = [
LandingFeature(
icon=f.get('icon', ''),
title=resolve_locale_text(f.get('title'), lang),
description=resolve_locale_text(f.get('description'), lang),
)
for f in (landing.features or [])
]
return LandingConfigResponse(
slug=landing.slug,
title=resolve_locale_text(landing.title, lang),
subtitle=resolve_locale_text(landing.subtitle, lang) or None,
features=features,
footer_text=resolve_locale_text(landing.footer_text, lang) or None,
tariffs=tariffs,
payment_methods=payment_methods,
gift_enabled=landing.gift_enabled,
custom_css=landing.custom_css,
meta_title=resolve_locale_text(landing.meta_title, lang) or None,
meta_description=resolve_locale_text(landing.meta_description, lang) or None,
discount=discount,
background_config=landing.background_config,
)
@router.post('/{slug}/purchase', response_model=PurchaseResponse)
async def create_landing_purchase(
slug: str,
body: PurchaseRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a guest purchase on a landing page.
No authentication required.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=5, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many purchase attempts, please try again later',
)
landing = await get_active_landing_by_slug(db, slug)
if landing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Landing page not found',
)
if body.is_gift and not landing.gift_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Gift purchases are not enabled for this landing page',
)
# Validate payment method is available on this landing.
# The frontend may send a suffixed method ID (e.g. "platega_2", "yookassa_sbp")
# to select a specific sub-option. We match against the base method_id and
# validate the suffix against known & enabled sub-options.
raw_methods = landing.payment_methods or []
method_defaults = _get_method_defaults()
method_config = next((m for m in raw_methods if m.get('method_id') == body.payment_method), None)
if method_config is None:
# Try matching by prefix: "platega_2" → base "platega"
# Sort by length descending so "freekassa_sbp" is checked before "freekassa"
sorted_methods = sorted(raw_methods, key=lambda m: len(m.get('method_id', '')), reverse=True)
for m in sorted_methods:
mid = m.get('method_id', '')
if body.payment_method.startswith(mid + '_'):
suffix = body.payment_method[len(mid) + 1 :]
# Validate suffix is a known sub-option
method_def = method_defaults.get(mid)
available = (method_def.get('available_sub_options') if method_def else None) or []
valid_ids = {opt['id'] for opt in available}
if suffix not in valid_ids:
break # invalid suffix → reject
# Validate suffix is enabled on this landing
raw_sub_options = m.get('sub_options') # dict[str, bool] | None
if raw_sub_options is not None and not raw_sub_options.get(suffix, True):
break # disabled sub-option → reject
method_config = m
break
if method_config is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Payment method is not available on this landing page',
)
# Validate tariff + period + calculate price
try:
tariff, amount_kopeks = await validate_and_calculate(db, landing, body.tariff_id, body.period_days)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
# Gift purchases require the tariff to be visible in the gift section
if body.is_gift and not tariff.show_in_gift:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This tariff is not available for gift purchases',
)
# Validate amount against per-method min/max limits (before creating purchase record)
min_amount = method_config.get('min_amount_kopeks')
max_amount = method_config.get('max_amount_kopeks')
if min_amount is not None and amount_kopeks < min_amount:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Amount is below the minimum ({settings.format_price(min_amount)}) for this payment method',
)
if max_amount is not None and amount_kopeks > max_amount:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Amount exceeds the maximum ({settings.format_price(max_amount)}) for this payment method',
)
# Create purchase record (no commit yet — wait for payment creation)
purchase = await create_purchase(
db,
landing=landing,
tariff=tariff,
period_days=body.period_days,
amount_kopeks=amount_kopeks,
contact_type=body.contact_type,
contact_value=body.contact_value,
payment_method=body.payment_method,
is_gift=body.is_gift,
gift_recipient_type=body.gift_recipient_type,
gift_recipient_value=body.gift_recipient_value,
gift_message=body.gift_message,
commit=False,
)
# Determine return URL: per-method override → default cabinet URL
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
default_return_url = f'{cabinet_base}/buy/success/{purchase.token}'
method_return_url = method_config.get('return_url')
if method_return_url:
# Allow {token} placeholder in custom return URLs
return_url = method_return_url.replace('{token}', purchase.token)
else:
return_url = default_return_url
payment_service = PaymentService()
payment_result = await payment_service.create_guest_payment(
db=db,
amount_kopeks=amount_kopeks,
payment_method=body.payment_method,
description=f'{tariff.name}{body.period_days}d',
purchase_token=purchase.token,
return_url=return_url,
)
if payment_result is None:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Payment provider is unavailable, please try again later',
)
payment_url = payment_result.get('payment_url')
if not payment_url:
await db.rollback()
logger.error(
'Payment created but no payment_url returned',
purchase_token=purchase.token[:5],
provider=payment_result.get('provider'),
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Payment provider returned an invalid response',
)
await db.commit()
await db.refresh(purchase)
return PurchaseResponse(
purchase_token=purchase.token,
payment_url=payment_url,
)
+3 -1
View File
@@ -82,7 +82,9 @@ class OAuthCallbackRequest(BaseModel):
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
)
# --- Endpoints ---
+16 -11
View File
@@ -204,15 +204,11 @@ async def get_loyalty_tiers(
total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id)
total_spent_rubles = total_spent_kopeks / 100
# Get user's current promo group
await db.refresh(user, ['promo_group', 'user_promo_groups'])
current_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
current_tier_name = current_promo_group.name if current_promo_group else None
# Get all auto-assign promo groups (sorted by threshold ascending)
auto_groups = await get_auto_assign_promo_groups(db)
tiers: list[LoyaltyTierInfo] = []
current_tier_name: str | None = None
next_tier_name: str | None = None
next_tier_threshold: float | None = None
@@ -220,7 +216,15 @@ async def get_loyalty_tiers(
threshold_kopeks = group.auto_assign_total_spent_kopeks or 0
threshold_rubles = threshold_kopeks / 100
is_achieved = total_spent_kopeks >= threshold_kopeks
is_current = current_promo_group and current_promo_group.id == group.id
# Track highest achieved tier as "current" (by spending, not by assignment)
if is_achieved:
current_tier_name = group.name
# Find next tier (first not achieved)
if not is_achieved and next_tier_name is None:
next_tier_name = group.name
next_tier_threshold = threshold_rubles
# Get period discounts
period_discounts = {}
@@ -241,15 +245,16 @@ async def get_loyalty_tiers(
traffic_discount_percent=group.traffic_discount_percent or 0,
device_discount_percent=group.device_discount_percent or 0,
period_discounts=period_discounts,
is_current=is_current,
is_current=False,
is_achieved=is_achieved,
)
)
# Find next tier (first not achieved)
if not is_achieved and next_tier_name is None:
next_tier_name = group.name
next_tier_threshold = threshold_rubles
# Mark only the highest achieved tier as "current"
for tier in reversed(tiers):
if tier.is_achieved:
tier.is_current = True
break
# Calculate progress to next tier
progress_percent = 0.0
+2 -2
View File
@@ -92,8 +92,7 @@ async def get_referral_info(
available_balance = min(user.balance_kopeks, referral_entitlement)
# Build referral link
bot_username = settings.get_bot_username() or 'bot'
referral_link = f'https://t.me/{bot_username}?start={user.referral_code}'
referral_link = settings.get_referral_link(user.referral_code) if user.referral_code else ''
return ReferralInfoResponse(
referral_code=user.referral_code or '',
@@ -243,5 +242,6 @@ async def get_referral_terms():
first_topup_bonus_rubles=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100,
inviter_bonus_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
inviter_bonus_rubles=settings.REFERRAL_INVITER_BONUS_KOPEKS / 100,
max_commission_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
)
+302 -297
View File
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import PERIOD_PRICES, settings
from app.config import settings
from app.database.crud.server_squad import get_server_squad_by_uuid
from app.database.crud.subscription import (
create_paid_subscription,
@@ -26,12 +26,17 @@ from app.services.notification_delivery_service import (
NotificationType,
notification_delivery_service,
)
from app.services.pricing_engine import pricing_engine
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_purchase_service import (
MiniAppSubscriptionPurchaseService,
PurchaseBalanceError,
PurchaseValidationError,
)
from app.services.subscription_renewal_service import (
SubscriptionRenewalChargeError,
SubscriptionRenewalService,
)
from app.services.subscription_service import SubscriptionService
from app.services.system_settings_service import bot_configuration_service
from app.services.user_cart_service import user_cart_service
@@ -98,12 +103,13 @@ def _apply_addon_discount(
Returns dict with keys: discounted, discount, percent
"""
from app.utils.pricing_utils import apply_percentage_discount
percent = _get_addon_discount_percent(user, category, period_days)
if percent <= 0 or amount <= 0:
return {'discounted': amount, 'discount': 0, 'percent': 0}
discount_value = int(amount * percent / 100)
discounted_amount = amount - discount_value
discounted_amount, discount_value = apply_percentage_discount(amount, percent)
return {
'discounted': discounted_amount,
'discount': discount_value,
@@ -140,6 +146,7 @@ def _subscription_to_response(
actual_status = subscription.actual_status
is_expired = actual_status == 'expired'
is_active = actual_status in ('active', 'trial')
is_limited = actual_status == 'limited'
# Calculate time remaining
days_left = 0
@@ -225,7 +232,7 @@ def _subscription_to_response(
traffic_limit_gb=traffic_limit_gb,
traffic_used_gb=round(traffic_used_gb, 2),
traffic_used_percent=round(traffic_used_percent, 1),
device_limit=subscription.device_limit or 1,
device_limit=subscription.device_limit or 0,
connected_squads=subscription.connected_squads or [],
servers=servers or [],
autopay_enabled=subscription.autopay_enabled or False,
@@ -234,6 +241,7 @@ def _subscription_to_response(
hide_subscription_link=hide_link,
is_active=is_active,
is_expired=is_expired,
is_limited=is_limited,
traffic_purchases=traffic_purchases or [],
is_daily=is_daily,
is_daily_paused=is_daily_paused,
@@ -323,77 +331,34 @@ async def get_renewal_options(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get available subscription renewal options with prices."""
options = []
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription:
return []
# В режиме тарифов берём цены из тарифа пользователя
tariff_prices = None
tariff_periods = None
extra_devices = 0
tariff_device_price = 0
if settings.is_tariffs_mode():
subscription = await get_subscription_by_user_id(db, user.id)
if subscription and subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.period_prices:
tariff_prices = {int(k): v for k, v in tariff.period_prices.items()}
tariff_periods = sorted(tariff_prices.keys())
# Учитываем докупленные устройства сверх тарифа
extra_devices = max(0, (subscription.device_limit or 0) - (tariff.device_limit or 0))
if extra_devices > 0:
tariff_device_price = (
tariff.device_price_kopeks
if tariff.device_price_kopeks is not None
else settings.PRICE_PER_DEVICE
)
# Используем периоды тарифа или стандартные
if tariff_periods:
periods = tariff_periods
# Determine available periods
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
periods = sorted(int(k) for k in subscription.tariff.period_prices.keys())
else:
periods = settings.get_available_renewal_periods()
for period in periods:
# Получаем цену из тарифа или из PERIOD_PRICES
if tariff_prices and period in tariff_prices:
price_kopeks = tariff_prices[period]
else:
price_kopeks = PERIOD_PRICES.get(period, 0)
options = []
if price_kopeks <= 0:
for period in periods:
pricing = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
if pricing.final_total <= 0 and pricing.base_price <= 0:
continue
# Добавляем стоимость докупленных устройств за период продления
if extra_devices > 0 and tariff_device_price > 0:
from app.utils.pricing_utils import calculate_months_from_days
months = calculate_months_from_days(period)
price_kopeks += extra_devices * tariff_device_price * months
# Apply user's discount if any
original_price = price_kopeks
discount_percent = 0
if hasattr(user, 'get_promo_discount'):
discount_percent = user.get_promo_discount('period', period)
if discount_percent > 0:
price_kopeks = int(price_kopeks * (100 - discount_percent) / 100)
# Apply promo_offer discount (временная скидка, как в /renew)
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
if promo_offer_discount_percent > 0:
price_kopeks = price_kopeks - price_kopeks * promo_offer_discount_percent // 100
# Комбинированный процент скидки для отображения
combined_discount = discount_percent
if original_price > 0 and original_price != price_kopeks:
total_discount = original_price - price_kopeks
combined_discount = int(total_discount * 100 / original_price)
original_price = pricing.original_total
combined_discount = 0
if original_price > 0 and original_price != pricing.final_total:
combined_discount = int((original_price - pricing.final_total) * 100 / original_price)
options.append(
RenewalOptionResponse(
period_days=period,
price_kopeks=price_kopeks,
price_rubles=price_kopeks / 100,
price_kopeks=pricing.final_total,
price_rubles=pricing.final_total / 100,
discount_percent=combined_discount,
original_price_kopeks=original_price if combined_discount > 0 else None,
)
@@ -423,57 +388,42 @@ async def renew_subscription(
detail='No subscription found',
)
# В режиме тарифов берём цену из тарифа пользователя
price_kopeks = 0
tariff = None
if settings.is_tariffs_mode() and user.subscription.tariff_id:
tariff = await get_tariff_by_id(db, user.subscription.tariff_id)
if tariff and tariff.period_prices:
price_kopeks = tariff.period_prices.get(str(request.period_days), 0)
# Validate period_days against available periods (prevent arbitrary periods)
subscription = user.subscription
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
available_periods = [int(p) for p in subscription.tariff.period_prices.keys()]
else:
available_periods = settings.get_available_renewal_periods()
# Fallback на PERIOD_PRICES
if price_kopeks <= 0:
price_kopeks = PERIOD_PRICES.get(request.period_days, 0)
if request.period_days not in available_periods:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Selected renewal period is not available',
)
if price_kopeks <= 0:
# Unified pricing via PricingEngine
pricing = await pricing_engine.calculate_renewal_price(
db,
subscription,
request.period_days,
user=user,
)
price_kopeks = pricing.final_total
promo_offer_discount_value = pricing.promo_offer_discount
promo_offer_discount_percent = pricing.breakdown.get('offer_discount_pct', 0)
if price_kopeks <= 0 and pricing.base_price <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid renewal period',
)
# Добавляем стоимость докупленных устройств сверх тарифа
if tariff:
extra_devices = max(0, (user.subscription.device_limit or 0) - (tariff.device_limit or 0))
if extra_devices > 0:
from app.utils.pricing_utils import calculate_months_from_days
original_price_kopeks = pricing.original_total
discount_percent = 0
if original_price_kopeks > 0 and original_price_kopeks != price_kopeks:
discount_percent = int((original_price_kopeks - price_kopeks) * 100 / original_price_kopeks)
device_price = (
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
)
months = calculate_months_from_days(request.period_days)
price_kopeks += extra_devices * device_price * months
# Apply promo group discount
original_price_kopeks = price_kopeks
promo_group_discount_percent = 0
if hasattr(user, 'get_promo_discount'):
promo_group_discount_percent = user.get_promo_discount('period', request.period_days)
if promo_group_discount_percent > 0:
price_kopeks = int(price_kopeks * (100 - promo_group_discount_percent) / 100)
# Apply promo offer discount (temporary discount from promo offers)
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
promo_offer_discount_value = 0
if promo_offer_discount_percent > 0:
promo_offer_discount_value = price_kopeks * promo_offer_discount_percent // 100
price_kopeks = price_kopeks - promo_offer_discount_value
# Combined discount percent for display
discount_percent = promo_group_discount_percent
if promo_offer_discount_percent > 0 and original_price_kopeks > 0:
total_discount = original_price_kopeks - price_kopeks
discount_percent = int(total_discount * 100 / original_price_kopeks)
tariff = user.subscription.tariff if user.subscription.tariff_id else None
# Check balance
if user.balance_kopeks < price_kopeks:
@@ -510,12 +460,16 @@ async def renew_subscription(
'source': 'cabinet',
}
# Add tariff parameters for tariffs mode
# Add subscription parameters for auto-purchase
if tariff_id:
cart_data['traffic_limit_gb'] = tariff_traffic_limit_gb
# Сохраняем актуальный device_limit подписки (включая докупленные устройства)
cart_data['device_limit'] = user.subscription.device_limit
cart_data['allowed_squads'] = tariff_allowed_squads
else:
# Classic mode: сохраняем текущие параметры подписки для корректной автопокупки
cart_data['device_limit'] = user.subscription.device_limit
cart_data['traffic_limit_gb'] = user.subscription.traffic_limit_gb
try:
await user_cart_service.save_user_cart(user.id, cart_data)
@@ -534,19 +488,21 @@ async def renew_subscription(
},
)
# Deduct balance (centralized: row-level lock, promo consumption, paid subscription flag)
from app.database.crud.user import subtract_user_balance
# Centralized renewal: balance deduction, extension, RemnaWave sync, admin notification,
# server price recording, and compensating refund on failure.
renewal_description = f'Продление подписки на {request.period_days} дней' + (f' ({tariff.name})' if tariff else '')
success = await subtract_user_balance(
db,
user,
price_kopeks,
renewal_description,
consume_promo_offer=promo_offer_discount_value > 0,
mark_as_paid_subscription=True,
)
if not success:
renewal_service = SubscriptionRenewalService()
try:
result = await renewal_service.finalize(
db,
user,
subscription,
pricing,
description=renewal_description,
payment_method=PaymentMethod.BALANCE,
)
except SubscriptionRenewalChargeError:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
@@ -555,104 +511,9 @@ async def renew_subscription(
},
)
# Создаём транзакцию для учёта списания
transaction = await create_transaction(
db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price_kopeks,
description=renewal_description,
payment_method=PaymentMethod.BALANCE,
)
await db.refresh(user, ['subscription'])
# Extend from end_date or now if expired
now = datetime.now(UTC)
was_expired = user.subscription.status in ('expired', 'disabled') or (
user.subscription.end_date is not None and user.subscription.end_date <= now
)
if user.subscription.end_date and user.subscription.end_date > now:
user.subscription.end_date = user.subscription.end_date + timedelta(days=request.period_days)
else:
user.subscription.end_date = now + timedelta(days=request.period_days)
user.subscription.start_date = now
user.subscription.status = 'active'
user.subscription.is_trial = False
# При продлении истёкшей подписки — сбрасываем докупки трафика (новый период)
if was_expired:
from sqlalchemy import delete as sql_delete
from app.database.models import TrafficPurchase
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == user.subscription.id))
purchased = user.subscription.purchased_traffic_gb or 0
if purchased > 0:
old_traffic = user.subscription.traffic_limit_gb
user.subscription.traffic_limit_gb = max(0, (user.subscription.traffic_limit_gb or 0) - purchased)
logger.info(
'Сброс докупок трафика при продлении истёкшей подписки',
old_traffic=old_traffic,
new_traffic=user.subscription.traffic_limit_gb,
)
user.subscription.purchased_traffic_gb = 0
user.subscription.traffic_reset_at = None
if settings.RESET_TRAFFIC_ON_PAYMENT:
user.subscription.traffic_used_gb = 0.0
await db.commit()
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
if getattr(user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(
db,
user.subscription,
reset_traffic=was_expired and settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason='subscription renewal (cabinet)',
)
else:
await subscription_service.create_remnawave_user(
db,
user.subscription,
reset_traffic=was_expired and settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason='subscription renewal (cabinet)',
)
except Exception as e:
logger.error('Failed to sync subscription renewal with RemnaWave', error=e)
# Отправляем уведомление админам о продлении подписки
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_purchase_notification(
db=db,
user=user,
subscription=user.subscription,
transaction=transaction,
period_days=request.period_days,
was_trial_conversion=False,
amount_kopeks=price_kopeks,
purchase_type='renewal',
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for subscription renewal', error=e)
response = {
'message': 'Subscription renewed successfully',
'new_end_date': user.subscription.end_date.isoformat(),
'new_end_date': result.subscription.end_date.isoformat(),
'amount_paid_kopeks': price_kopeks,
}
@@ -856,15 +717,15 @@ async def purchase_traffic(
# Пропорциональный расчёт применяем только в классическом режиме.
if is_tariff_mode:
prorated_price = base_price_kopeks
months_charged = 1
days_charged = 30
else:
prorated_price, months_charged = calculate_prorated_price(
prorated_price, days_charged = calculate_prorated_price(
base_price_kopeks,
subscription.end_date,
)
# Apply discount from promo group using proper method
period_hint_days = months_charged * 30 if months_charged > 0 else 30
period_hint_days = days_charged if days_charged > 0 else 30
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, period_hint_days)
final_price = discount_result['discounted']
traffic_discount_percent = discount_result['percent']
@@ -929,7 +790,7 @@ async def purchase_traffic(
# Добавляем трафик (add_subscription_traffic обновляет purchased_traffic_gb, traffic_reset_at и коммитит)
await add_subscription_traffic(db, subscription, request.gb)
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
from app.database.crud.subscription import reactivate_subscription
await reactivate_subscription(db, subscription)
@@ -939,6 +800,9 @@ async def purchase_traffic(
subscription_service = SubscriptionService()
if getattr(user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if subscription.status == 'active':
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
else:
await subscription_service.create_remnawave_user(db, subscription)
except Exception as e:
@@ -1004,9 +868,10 @@ async def purchase_devices_legacy(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Purchase additional device slots (legacy endpoint without tariff support).
"""Purchase additional device slots (legacy endpoint).
DEPRECATED: Use /devices/purchase instead for full tariff and discount support.
Now uses tariff-aware pricing when subscription has a tariff_id.
"""
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
@@ -1029,8 +894,34 @@ async def purchase_devices_legacy(
detail='No subscription found',
)
price_per_device = settings.PRICE_PER_DEVICE
base_total_price = price_per_device * request.devices
if subscription.status not in ['active', 'trial']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Ваша подписка неактивна',
)
# Get tariff for device price (if exists)
tariff = None
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
# Determine device price and max limit from tariff or settings
if tariff and tariff.device_price_kopeks is not None:
device_price = tariff.device_price_kopeks
max_device_limit = tariff.max_device_limit
else:
device_price = settings.PRICE_PER_DEVICE
max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
if not device_price or device_price <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Докупка устройств недоступна',
)
base_total_price = device_price * request.devices
# Apply discount from promo group
discount_result = _apply_addon_discount(user, 'devices', base_total_price, 30)
@@ -1044,12 +935,11 @@ async def purchase_devices_legacy(
# Check max devices limit (under row lock — prevents concurrent purchases exceeding limit)
current_devices = subscription.device_limit or 1
new_devices = current_devices + request.devices
max_devices = settings.MAX_DEVICES_LIMIT
if new_devices > max_devices:
if max_device_limit and new_devices > max_device_limit:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Maximum device limit is {max_devices}',
detail=f'Максимальное количество устройств: {max_device_limit}',
)
# Check balance
@@ -1104,6 +994,7 @@ async def purchase_devices_legacy(
description=description,
create_transaction=True,
payment_method=PaymentMethod.BALANCE,
transaction_type=TransactionType.SUBSCRIPTION_PAYMENT,
)
if not success:
raise HTTPException(
@@ -1123,7 +1014,7 @@ async def purchase_devices_legacy(
actual_current = subscription.device_limit or 1
actual_new = actual_current + request.devices
if max_devices > 0 and actual_new > max_devices:
if max_device_limit and actual_new > max_device_limit:
# Concurrent purchase already exceeded limit — refund balance
user_refund = await db.execute(
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
@@ -1133,14 +1024,25 @@ async def purchase_devices_legacy(
await db.commit()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f'Maximum device limit is {max_devices}. Balance refunded.',
detail=f'Максимальное количество устройств: {max_device_limit}. Баланс возвращён.',
)
# Add devices (under lock)
subscription.device_limit = actual_new
await db.commit()
await db.refresh(subscription)
await db.refresh(user)
# Sync with RemnaWave
try:
service = SubscriptionService()
if getattr(user, 'remnawave_uuid', None):
await service.update_remnawave_user(db, subscription)
else:
await service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync devices with RemnaWave (legacy endpoint)', error=e)
# Отправляем уведомление админам
try:
from aiogram import Bot
@@ -1427,7 +1329,7 @@ async def activate_trial(
duration_days=trial_duration,
traffic_limit_gb=trial_traffic_limit,
device_limit=trial_device_limit,
connected_squads=trial_squads if trial_squads else None,
connected_squads=trial_squads or None,
tariff_id=tariff_id_for_trial,
)
@@ -1832,11 +1734,11 @@ async def submit_purchase(
user=user,
notification_type=notification_type,
context={
'subscription': subscription,
'expires_at': end_date_str, # for SUBSCRIPTION_ACTIVATED
'new_expires_at': end_date_str, # for SUBSCRIPTION_RENEWED
'traffic_limit_gb': subscription.traffic_limit_gb,
'device_limit': subscription.device_limit,
'tariff_name': '', # classic mode has no tariff
},
bot=None,
)
@@ -1862,7 +1764,7 @@ async def submit_purchase(
period_days=selection.period.days,
was_trial_conversion=result.get('was_trial_conversion', False),
amount_kopeks=pricing.final_total,
purchase_type='renewal' if not is_new_subscription else None,
purchase_type='renewal' if not is_new_subscription else 'first_purchase',
)
finally:
await bot.session.close()
@@ -2282,7 +2184,6 @@ async def purchase_tariff(
user=user,
notification_type=notification_type,
context={
'subscription': subscription,
'expires_at': end_date_str, # for SUBSCRIPTION_ACTIVATED
'new_expires_at': end_date_str, # for SUBSCRIPTION_RENEWED
'traffic_limit_gb': subscription.traffic_limit_gb,
@@ -2316,7 +2217,7 @@ async def purchase_tariff(
period_days=period_days,
was_trial_conversion=False,
amount_kopeks=price_kopeks,
purchase_type='renewal' if not was_new_subscription else None,
purchase_type='renewal' if not was_new_subscription else 'first_purchase',
)
finally:
await bot.session.close()
@@ -2482,6 +2383,7 @@ async def purchase_devices(
description=description,
create_transaction=True,
payment_method=PaymentMethod.BALANCE,
transaction_type=TransactionType.SUBSCRIPTION_PAYMENT,
)
if not success:
raise HTTPException(
@@ -2604,7 +2506,6 @@ async def save_traffic_cart(
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, bool]:
"""Save cart for traffic purchase (for insufficient balance flow)."""
from app.utils.pricing_utils import calculate_prorated_price
await db.refresh(user, ['subscription'])
subscription = user.subscription
@@ -2675,26 +2576,18 @@ async def save_traffic_cart(
)
base_price_kopeks = matching_pkg['price']
# Apply promo group discount
traffic_discount_percent = 0
promo_group = (
user.get_primary_promo_group()
if hasattr(user, 'get_primary_promo_group')
else getattr(user, 'promo_group', None)
)
if promo_group:
apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True)
if apply_to_addons:
traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0)))
# Calculate prorated price (days-based), then apply discount
from app.utils.pricing_utils import calculate_prorated_price as _calc_prorated
if traffic_discount_percent > 0:
base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100)
# Calculate prorated price
final_price, _ = calculate_prorated_price(
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
prorated_price, _ = _calc_prorated(
base_price_kopeks,
subscription.end_date,
)
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, days_left)
final_price = discount_result['discounted']
traffic_discount_percent = discount_result['percent']
# Save cart for auto-purchase after balance top-up
cart_data = {
@@ -2772,14 +2665,26 @@ async def save_devices_cart(
days_left = max(1, (end_date - now).days)
total_days = 30
price_kopeks = int(device_price * request.devices * days_left / total_days)
price_kopeks = max(100, price_kopeks) # Minimum 1 ruble
base_total_price = int(device_price * request.devices * days_left / total_days)
base_total_price = max(100, base_total_price) # Minimum 1 ruble
# Apply discount from promo group
period_hint_days = days_left
discount_result = _apply_addon_discount(user, 'devices', base_total_price, period_hint_days)
price_kopeks = discount_result['discounted']
devices_discount_percent = discount_result['percent']
# Ensure minimum price after discount (except for 100% discount)
if devices_discount_percent < 100 and price_kopeks > 0:
price_kopeks = max(100, price_kopeks)
# Save cart for auto-purchase after balance top-up
cart_data = {
'cart_mode': 'add_devices',
'devices_to_add': request.devices,
'price_kopeks': price_kopeks,
'base_price_kopeks': base_total_price,
'discount_percent': devices_discount_percent,
'source': 'cabinet',
}
await user_cart_service.save_user_cart(user.id, cart_data)
@@ -2857,10 +2762,9 @@ async def get_device_price(
days_left = max(1, (end_date - now).days)
total_days = 30
# Calculate base price before discount
base_price_per_device = int(device_price * days_left / total_days)
base_price_per_device = max(100, base_price_per_device)
base_total_price = base_price_per_device * devices
# Calculate base price before discount (total first, then floor)
base_total_price = int(device_price * devices * days_left / total_days)
base_total_price = max(100, base_total_price)
# Apply discount from promo group
period_hint_days = days_left
@@ -2869,7 +2773,7 @@ async def get_device_price(
devices_discount_percent = discount_result['percent']
discount_value = discount_result['discount']
# Calculate per-device price after discount
# Ensure minimum price after discount (except for 100% discount)
if devices_discount_percent < 100 and total_price_kopeks > 0:
total_price_kopeks = max(100, total_price_kopeks)
price_per_device_kopeks = total_price_kopeks // devices if devices > 0 else 0
@@ -3216,7 +3120,7 @@ async def update_countries(
else:
discounted_per_month = server_price_per_month
charged_price, charged_months = calculate_prorated_price(
charged_price, charged_days = calculate_prorated_price(
discounted_per_month,
user.subscription.end_date,
)
@@ -3557,7 +3461,7 @@ async def get_devices(
return {
'devices': [],
'total': 0,
'device_limit': user.subscription.device_limit or 1,
'device_limit': user.subscription.device_limit or 0,
}
try:
@@ -3585,7 +3489,7 @@ async def get_devices(
return {
'devices': formatted_devices,
'total': response.get('total', len(formatted_devices)),
'device_limit': user.subscription.device_limit or 1,
'device_limit': user.subscription.device_limit or 0,
}
except Exception as e:
@@ -3593,7 +3497,7 @@ async def get_devices(
return {
'devices': [],
'total': 0,
'device_limit': user.subscription.device_limit or 1,
'device_limit': user.subscription.device_limit or 0,
}
@@ -4127,6 +4031,16 @@ async def switch_tariff(
detail='No active subscription with tariff',
)
# Lock subscription row to prevent concurrent tariff switches
locked_result = await db.execute(
select(Subscription)
.where(Subscription.id == user.subscription.id)
.with_for_update()
.execution_options(populate_existing=True)
)
subscription = locked_result.scalar_one()
user.subscription = subscription
# Use actual_status for correct status check (handles time-based expiration)
actual_status = user.subscription.actual_status
if actual_status == 'expired':
@@ -4289,6 +4203,7 @@ async def switch_tariff(
upgrade_cost,
description,
mark_as_paid_subscription=True,
commit=False,
)
if not success:
raise HTTPException(
@@ -4296,7 +4211,7 @@ async def switch_tariff(
detail='Failed to charge balance',
)
# Create transaction
# Create transaction (commit=False to keep FOR UPDATE lock held)
switch_transaction = await create_transaction(
db=db,
user_id=user.id,
@@ -4304,54 +4219,96 @@ async def switch_tariff(
amount_kopeks=upgrade_cost,
description=description,
payment_method=PaymentMethod.BALANCE,
commit=False,
)
else:
# Free switch (downgrade) — record in history
description = f"Переход на тариф '{new_tariff.name}'"
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=0,
description=description,
commit=False,
)
# Update subscription
old_tariff_name = current_tariff.name if current_tariff else 'Unknown'
user.subscription.tariff_id = new_tariff.id
user.subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
user.subscription.device_limit = new_tariff.device_limit
user.subscription.connected_squads = new_tariff.allowed_squads or []
# Preserve extra purchased devices above the old tariff's base limit
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
# Re-load subscription to avoid MissingGreenlet from expired lazy relationship
# (subtract_user_balance re-selects User with populate_existing=True which expires relationships)
await db.refresh(user, ['subscription'])
subscription = user.subscription
subscription.tariff_id = new_tariff.id
subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
subscription.device_limit = calc_device_limit_on_tariff_switch(
current_device_limit=subscription.device_limit,
old_tariff_device_limit=current_tariff.device_limit if current_tariff else None,
new_tariff_device_limit=new_tariff.device_limit,
max_device_limit=new_tariff.max_device_limit,
)
subscription.connected_squads = new_tariff.allowed_squads or []
# Reset purchased traffic and delete TrafficPurchase records on tariff switch
from sqlalchemy import delete as sql_delete
from app.database.models import TrafficPurchase
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == user.subscription.id))
user.subscription.purchased_traffic_gb = 0
user.subscription.traffic_reset_at = None
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
user.subscription.traffic_used_gb = 0.0
subscription.traffic_used_gb = 0.0
if switching_to_daily:
# Switching TO daily - reset end_date to 1 day, set last_daily_charge_at
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
user.subscription.last_daily_charge_at = datetime.now(UTC)
user.subscription.is_daily_paused = False
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
subscription.last_daily_charge_at = datetime.now(UTC)
subscription.is_daily_paused = False
elif switching_from_daily:
user.subscription.end_date = datetime.now(UTC) + timedelta(days=new_period_days)
user.subscription.is_daily_paused = False
subscription.end_date = datetime.now(UTC) + timedelta(days=new_period_days)
subscription.is_daily_paused = False
user.subscription.updated_at = datetime.now(UTC)
subscription.updated_at = datetime.now(UTC)
await db.commit()
# Emit deferred side-effects after atomic commit
if upgrade_cost > 0 and switch_transaction:
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
switch_transaction,
amount_kopeks=upgrade_cost,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
payment_method=PaymentMethod.BALANCE,
)
# Sync with RemnaWave (optionally reset traffic based on admin setting)
should_reset_traffic = settings.RESET_TRAFFIC_ON_TARIFF_SWITCH
# Refresh subscription after commit (all objects are expired)
await db.refresh(subscription)
try:
subscription_service = SubscriptionService()
if getattr(user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(
db,
user.subscription,
subscription,
reset_traffic=should_reset_traffic,
reset_reason='смена тарифа',
)
else:
await subscription_service.create_remnawave_user(
db,
user.subscription,
subscription,
reset_traffic=should_reset_traffic,
reset_reason='смена тарифа',
)
@@ -4371,7 +4328,7 @@ async def switch_tariff(
logger.error('Failed to reset devices on tariff switch', error=e)
await db.refresh(user)
await db.refresh(user.subscription)
await db.refresh(subscription)
# Отправляем уведомление админам о смене тарифа
try:
@@ -4386,7 +4343,7 @@ async def switch_tariff(
await notification_service.send_subscription_purchase_notification(
db=db,
user=user,
subscription=user.subscription,
subscription=subscription,
transaction=switch_transaction if upgrade_cost > 0 else None,
period_days=remaining_days if remaining_days > 0 else new_period_days,
was_trial_conversion=False,
@@ -4402,7 +4359,7 @@ async def switch_tariff(
'success': True,
'message': f"Switched from '{old_tariff_name}' to '{new_tariff.name}'"
+ (' (devices reset)' if devices_reset else ''),
'subscription': _subscription_to_response(user.subscription),
'subscription': _subscription_to_response(subscription),
'old_tariff_name': old_tariff_name,
'new_tariff_id': new_tariff.id,
'new_tariff_name': new_tariff.name,
@@ -4451,19 +4408,28 @@ async def toggle_subscription_pause(
detail='Pause is only available for daily tariffs',
)
# Toggle pause state
is_currently_paused = getattr(user.subscription, 'is_daily_paused', False)
new_paused_state = not is_currently_paused
user.subscription.is_daily_paused = new_paused_state
# Сохраняем статус ДО изменения для проверки RemnaWave
# Determine current state
from app.database.models import SubscriptionStatus
was_disabled = user.subscription.status == SubscriptionStatus.DISABLED.value
is_currently_paused = getattr(user.subscription, 'is_daily_paused', False)
was_disabled = user.subscription.status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
)
# If resuming, check balance
# System-DISABLED subs (insufficient balance) should always be treated as needing resume,
# even if is_daily_paused is False (it's set by the system, not the user)
if was_disabled and not is_currently_paused:
new_paused_state = False # Force resume path
else:
new_paused_state = not is_currently_paused
user.subscription.is_daily_paused = new_paused_state
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# If resuming, check balance and charge
if not new_paused_state:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price > 0 and user.balance_kopeks < daily_price:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
@@ -4475,8 +4441,44 @@ async def toggle_subscription_pause(
},
)
# Restore ACTIVE status if was DISABLED
# Charge daily fee FIRST, then restore ACTIVE status
if was_disabled:
if daily_price > 0:
from app.database.crud.user import subtract_user_balance
deducted = await subtract_user_balance(
db,
user,
daily_price,
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
mark_as_paid_subscription=True,
)
if not deducted:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_balance',
'message': 'Balance deduction failed',
'required': daily_price,
'balance': user.balance_kopeks,
},
)
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
try:
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
)
except Exception as exc:
logger.warning('Failed to create resume transaction', error=exc)
# Balance deducted successfully — now activate
user.subscription.status = SubscriptionStatus.ACTIVE.value
user.subscription.last_daily_charge_at = datetime.now(UTC)
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
@@ -4486,14 +4488,17 @@ async def toggle_subscription_pause(
await db.refresh(user)
# Sync with RemnaWave only when resuming from DISABLED state
# При паузе НЕ отключаем - пользователь может пользоваться до конца оплаченного периода
# При возобновлении включаем только если подписка была отключена (DISABLED)
if not new_paused_state and user.remnawave_uuid and was_disabled:
if not new_paused_state and was_disabled:
try:
subscription_service = SubscriptionService()
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
await subscription_service.create_remnawave_user(
db,
user.subscription,
reset_traffic=False,
reset_reason=None,
)
except Exception as e:
logger.error('Error enabling RemnaWave user on resume', error=e)
logger.error('Error syncing RemnaWave user on resume', error=e)
if new_paused_state:
message = 'Daily subscription paused'
@@ -4581,7 +4586,7 @@ async def switch_traffic_package(
price_diff = int(price_diff * (100 - traffic_discount_percent) / 100)
# Prorated calculation
final_price, months_charged = calculate_prorated_price(price_diff, user.subscription.end_date)
final_price, days_charged = calculate_prorated_price(price_diff, user.subscription.end_date)
if user.balance_kopeks < final_price:
raise HTTPException(
+39 -15
View File
@@ -8,27 +8,43 @@ from pydantic import BaseModel, EmailStr, Field
class TelegramAuthRequest(BaseModel):
"""Request for Telegram WebApp initData authentication."""
init_data: str = Field(..., description='Telegram WebApp initData string')
init_data: str = Field(..., max_length=4096, description='Telegram WebApp initData string')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
)
class TelegramWidgetAuthRequest(BaseModel):
"""Request for Telegram Login Widget authentication."""
id: int = Field(..., description='Telegram user ID')
first_name: str = Field(..., description="User's first name")
last_name: str | None = Field(None, description="User's last name")
username: str | None = Field(None, description="User's username")
photo_url: str | None = Field(None, description="User's photo URL")
first_name: str = Field(..., max_length=64, description="User's first name")
last_name: str | None = Field(None, max_length=64, description="User's last name")
username: str | None = Field(None, max_length=32, description="User's username")
photo_url: str | None = Field(None, max_length=512, description="User's photo URL")
auth_date: int = Field(..., description='Unix timestamp of authentication')
hash: str = Field(..., description='Authentication hash')
hash: str = Field(..., min_length=64, max_length=64, description='Authentication hash')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
)
class TelegramOIDCAuthRequest(BaseModel):
"""Request for Telegram OIDC authentication (popup flow)."""
id_token: str = Field(..., max_length=4096, description='JWT id_token from Telegram OIDC popup')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
)
class EmailRegisterRequest(BaseModel):
@@ -41,7 +57,7 @@ class EmailRegisterRequest(BaseModel):
class EmailVerifyRequest(BaseModel):
"""Request to verify email with token."""
token: str = Field(..., description='Email verification token')
token: str = Field(..., max_length=2048, description='Email verification token')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
@@ -51,7 +67,7 @@ class EmailLoginRequest(BaseModel):
"""Request to login with email and password."""
email: EmailStr = Field(..., description='Email address')
password: str = Field(..., description='Password')
password: str = Field(..., min_length=1, max_length=128, description='Password')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
@@ -60,7 +76,7 @@ class EmailLoginRequest(BaseModel):
class RefreshTokenRequest(BaseModel):
"""Request to refresh access token."""
refresh_token: str = Field(..., description='Refresh token')
refresh_token: str = Field(..., max_length=2048, description='Refresh token')
class PasswordForgotRequest(BaseModel):
@@ -72,10 +88,16 @@ class PasswordForgotRequest(BaseModel):
class PasswordResetRequest(BaseModel):
"""Request to reset password with token."""
token: str = Field(..., description='Password reset token')
token: str = Field(..., max_length=2048, description='Password reset token')
password: str = Field(..., min_length=8, max_length=128, description='New password (min 8 chars)')
class AutoLoginRequest(BaseModel):
"""Request for auto-login from guest purchase success page."""
token: str = Field(..., max_length=2048, description='Auto-login JWT token')
class TokenResponse(BaseModel):
"""Token pair response."""
@@ -112,8 +134,10 @@ class EmailRegisterStandaloneRequest(BaseModel):
email: EmailStr = Field(..., description='Email address')
password: str = Field(..., min_length=8, max_length=128, description='Password (min 8 chars)')
first_name: str | None = Field(None, max_length=64, description='First name')
language: str = Field('ru', description='Preferred language')
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
language: str = Field('ru', max_length=5, pattern=r'^[a-z]{2}$', description='Preferred language (ISO 639-1)')
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
)
class CampaignBonusInfo(BaseModel):
@@ -154,7 +178,7 @@ class EmailChangeRequest(BaseModel):
class EmailChangeVerifyRequest(BaseModel):
"""Request to verify email change with code."""
code: str = Field(..., min_length=6, max_length=6, description='6-digit verification code')
code: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$', description='6-digit verification code')
class EmailChangeResponse(BaseModel):
+23 -5
View File
@@ -3,7 +3,7 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
class BalanceResponse(BaseModel):
@@ -26,8 +26,7 @@ class TransactionResponse(BaseModel):
created_at: datetime
completed_at: datetime | None = None
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class TransactionListResponse(BaseModel):
@@ -114,8 +113,7 @@ class PendingPaymentResponse(BaseModel):
user_telegram_id: int | None = None
user_username: str | None = None
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class PendingPaymentListResponse(BaseModel):
@@ -137,3 +135,23 @@ class ManualCheckResponse(BaseModel):
status_changed: bool = False
old_status: str | None = None
new_status: str | None = None
class SavedCardResponse(BaseModel):
"""Saved payment method (card) for recurrent payments."""
id: int
method_type: str
card_last4: str | None = None
card_type: str | None = None
title: str | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class SavedCardsListResponse(BaseModel):
"""List of saved payment methods."""
cards: list[SavedCardResponse]
recurrent_enabled: bool = False
+131
View File
@@ -0,0 +1,131 @@
"""Schemas for cabinet gift subscription feature."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field, model_validator
class GiftConfigSubOption(BaseModel):
id: str
name: str
class GiftConfigTariffPeriod(BaseModel):
days: int
price_kopeks: int
price_label: str
original_price_kopeks: int | None = None
discount_percent: int | None = None
class GiftConfigTariff(BaseModel):
id: int
name: str
description: str | None = None
traffic_limit_gb: int
device_limit: int
periods: list[GiftConfigTariffPeriod]
class GiftConfigPaymentMethod(BaseModel):
method_id: str
display_name: str
description: str | None = None
icon_url: str | None = None
min_amount_kopeks: int | None = None
max_amount_kopeks: int | None = None
sub_options: list[GiftConfigSubOption] | None = None
class GiftConfigResponse(BaseModel):
is_enabled: bool
tariffs: list[GiftConfigTariff] = []
payment_methods: list[GiftConfigPaymentMethod] = []
balance_kopeks: int = 0
currency_symbol: str = '\u20bd'
promo_group_name: str | None = None
active_discount_percent: int | None = None
active_discount_expires_at: datetime | None = None
class GiftPurchaseRequest(BaseModel):
tariff_id: int = Field(gt=0)
period_days: int = Field(gt=0, le=3650)
recipient_type: str | None = Field(default=None, pattern=r'^(email|telegram)$')
recipient_value: str | None = Field(default=None, max_length=255)
gift_message: str | None = Field(default=None, max_length=1000)
payment_mode: str = Field(pattern=r'^(balance|gateway)$')
payment_method: str | None = Field(default=None, max_length=50)
@model_validator(mode='after')
def validate_payment(self) -> GiftPurchaseRequest:
if self.payment_mode == 'gateway' and not self.payment_method:
raise ValueError('payment_method is required for gateway mode')
return self
class GiftPurchaseResponse(BaseModel):
status: str
purchase_token: str
payment_url: str | None = None
warning: str | None = None
class GiftPurchaseStatusResponse(BaseModel):
status: str
is_gift: bool = True
is_code_only: bool = False
purchase_token: str | None = None
recipient_contact_value: str | None = None
gift_message: str | None = None
tariff_name: str | None = None
period_days: int | None = None
warning: str | None = None
class PendingGiftResponse(BaseModel):
token: str
tariff_name: str | None = None
period_days: int
gift_message: str | None = None
sender_display: str | None = None
created_at: datetime | None = None
class SentGiftResponse(BaseModel):
"""A gift the current user has sent."""
token: str
tariff_name: str | None = None
period_days: int
device_limit: int = 1
status: str
gift_recipient_value: str | None = None
gift_message: str | None = None
activated_by_username: str | None = None
created_at: datetime | None = None
class ReceivedGiftResponse(BaseModel):
"""A gift the current user has received."""
token: str
tariff_name: str | None = None
period_days: int
device_limit: int = 1
status: str
sender_display: str | None = None
gift_message: str | None = None
created_at: datetime | None = None
class ActivateGiftRequest(BaseModel):
code: str = Field(min_length=1, max_length=100)
class ActivateGiftResponse(BaseModel):
status: str
tariff_name: str | None = None
period_days: int | None = None
+1
View File
@@ -80,4 +80,5 @@ class ReferralTermsResponse(BaseModel):
first_topup_bonus_rubles: float
inviter_bonus_kopeks: int
inviter_bonus_rubles: float
max_commission_payments: int = 0
partner_section_visible: bool = True
+1
View File
@@ -48,6 +48,7 @@ class SubscriptionData(BaseModel):
hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают)
is_active: bool
is_expired: bool
is_limited: bool = False
traffic_purchases: list[TrafficPurchaseInfo] = []
# Daily tariff fields
is_daily: bool = False
+36
View File
@@ -54,6 +54,7 @@ class TariffListItem(BaseModel):
is_daily: bool = False
daily_price_kopeks: int = 0
allow_traffic_topup: bool = True
show_in_gift: bool = True
traffic_limit_gb: int
device_limit: int
tier_level: int
@@ -112,6 +113,10 @@ class TariffDetailResponse(BaseModel):
daily_price_kopeks: int = 0
# Режим сброса трафика
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = None
# Показывать в подарках
show_in_gift: bool = True
created_at: datetime
updated_at: datetime | None = None
@@ -119,6 +124,17 @@ class TariffDetailResponse(BaseModel):
from_attributes = True
class ExternalSquadInfoResponse(BaseModel):
"""External squad info from RemnaWave."""
uuid: str
name: str
members_count: int
UUID_PATTERN = r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
class TariffCreateRequest(BaseModel):
"""Request to create a tariff."""
@@ -155,6 +171,10 @@ class TariffCreateRequest(BaseModel):
daily_price_kopeks: int = Field(0, ge=0)
# Режим сброса трафика
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
# Показывать в подарках
show_in_gift: bool = True
class TariffUpdateRequest(BaseModel):
@@ -192,6 +212,10 @@ class TariffUpdateRequest(BaseModel):
daily_price_kopeks: int | None = Field(None, ge=0)
# Режим сброса трафика
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
# Показывать в подарках
show_in_gift: bool | None = None
class TariffSortOrderRequest(BaseModel):
@@ -226,3 +250,15 @@ class TariffStatsResponse(BaseModel):
trial_subscriptions: int
revenue_kopeks: int
revenue_rubles: float
class SyncSquadsResponse(BaseModel):
"""Response after syncing squads for tariff subscriptions."""
tariff_id: int
tariff_name: str
total_subscriptions: int
updated_count: int
failed_count: int
skipped_count: int
errors: list[str] = Field(default_factory=list)
+43 -5
View File
@@ -1,13 +1,13 @@
"""Schemas for Admin Users management in cabinet."""
from datetime import datetime
from enum import Enum
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, Field
class UserStatusEnum(str, Enum):
class UserStatusEnum(StrEnum):
"""User status enum."""
ACTIVE = 'active'
@@ -15,17 +15,18 @@ class UserStatusEnum(str, Enum):
DELETED = 'deleted'
class SubscriptionStatusEnum(str, Enum):
class SubscriptionStatusEnum(StrEnum):
"""Subscription status enum."""
TRIAL = 'trial'
ACTIVE = 'active'
EXPIRED = 'expired'
DISABLED = 'disabled'
LIMITED = 'limited'
PENDING = 'pending'
class SortByEnum(str, Enum):
class SortByEnum(StrEnum):
"""Sort options for users list."""
CREATED_AT = 'created_at'
@@ -281,7 +282,7 @@ class UpdateSubscriptionRequest(BaseModel):
"""Request to update user subscription."""
action: str = Field(
..., description='Action: extend, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
..., description='Action: extend, shorten, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
)
# For extend action
@@ -696,3 +697,40 @@ class DisableUserResponse(BaseModel):
panel_deactivated: bool = False
user_blocked: bool = False
panel_error: str | None = None
# === Gifts ===
class AdminUserGiftItem(BaseModel):
"""Gift item for admin user detail view."""
id: int
token: str
status: str
tariff_name: str | None = None
period_days: int
device_limit: int = 1
amount_kopeks: int
payment_method: str | None = None
gift_recipient_type: str | None = None
gift_recipient_value: str | None = None
gift_message: str | None = None
buyer_user_id: int | None = None
buyer_username: str | None = None
buyer_full_name: str | None = None
receiver_user_id: int | None = None
receiver_username: str | None = None
receiver_full_name: str | None = None
created_at: datetime | None = None
paid_at: datetime | None = None
delivered_at: datetime | None = None
class AdminUserGiftsResponse(BaseModel):
"""Response with sent and received gifts for admin user detail."""
sent: list[AdminUserGiftItem] = []
received: list[AdminUserGiftItem] = []
sent_total: int = 0
received_total: int = 0
+3 -3
View File
@@ -1,7 +1,7 @@
"""Схемы для колеса удачи (Fortune Wheel)."""
from datetime import datetime
from enum import Enum
from enum import StrEnum
from pydantic import BaseModel, Field
@@ -9,14 +9,14 @@ from pydantic import BaseModel, Field
# ==================== ENUMS ====================
class WheelPaymentType(str, Enum):
class WheelPaymentType(StrEnum):
"""Способы оплаты спина."""
TELEGRAM_STARS = 'telegram_stars'
SUBSCRIPTION_DAYS = 'subscription_days'
class WheelPrizeType(str, Enum):
class WheelPrizeType(StrEnum):
"""Типы призов."""
SUBSCRIPTION_DAYS = 'subscription_days'
+36 -17
View File
@@ -1,8 +1,10 @@
"""Email service for sending verification and password reset emails."""
import html
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate, make_msgid
import structlog
@@ -30,7 +32,7 @@ class EmailService:
def _get_smtp_connection(self) -> smtplib.SMTP:
"""Create and return SMTP connection."""
smtp = smtplib.SMTP(self.host, self.port)
smtp = smtplib.SMTP(self.host, self.port, timeout=30)
smtp.ehlo()
if self.use_tls:
@@ -69,11 +71,19 @@ class EmailService:
logger.warning('SMTP is not configured, cannot send email')
return False
# Defensive: strip newlines to prevent header injection
to_email = to_email.strip().replace('\n', '').replace('\r', '')
subject = subject.replace('\n', '').replace('\r', '')
try:
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = f'{self.from_name} <{self.from_email}>'
safe_from_name = self.from_name.replace('\n', '').replace('\r', '') if self.from_name else ''
safe_from_email = self.from_email.replace('\n', '').replace('\r', '') if self.from_email else ''
msg['From'] = f'{safe_from_name} <{safe_from_email}>'
msg['To'] = to_email
msg['Date'] = formatdate(localtime=False)
msg['Message-ID'] = make_msgid(domain=self.from_email.split('@')[-1])
# Plain text version
if body_text is None:
@@ -133,10 +143,13 @@ class EmailService:
full_url = f'{verification_url}?token={verification_token}'
expire_hours = settings.get_cabinet_email_verification_expire_hours()
# Escape user-provided values for HTML context
safe_username = html.escape(username) if username else None
# Localized content
texts = {
'ru': {
'greeting': f'Здравствуйте{", " + username if username else ""}!',
'greeting': f'Здравствуйте{", " + safe_username if safe_username else ""}!',
'subject': 'Подтверждение email адреса',
'intro': 'Спасибо за регистрацию! Пожалуйста, подтвердите ваш email адрес, нажав на кнопку ниже:',
'button': 'Подтвердить email',
@@ -146,7 +159,7 @@ class EmailService:
'regards': 'С уважением,',
},
'en': {
'greeting': f'Hello{", " + username if username else ""}!',
'greeting': f'Hello{", " + safe_username if safe_username else ""}!',
'subject': 'Verify your email address',
'intro': 'Thank you for registering! Please verify your email address by clicking the button below:',
'button': 'Verify Email',
@@ -156,7 +169,7 @@ class EmailService:
'regards': 'Best regards,',
},
'zh': {
'greeting': f'您好{", " + username if username else ""}!',
'greeting': f'您好{", " + safe_username if safe_username else ""}!',
'subject': '验证您的邮箱地址',
'intro': '感谢您的注册!请点击下方按钮验证您的邮箱地址:',
'button': '验证邮箱',
@@ -166,7 +179,7 @@ class EmailService:
'regards': '此致,',
},
'ua': {
'greeting': f'Вітаємо{", " + username if username else ""}!',
'greeting': f'Вітаємо{", " + safe_username if safe_username else ""}!',
'subject': 'Підтвердження email адреси',
'intro': 'Дякуємо за реєстрацію! Будь ласка, підтвердіть вашу email адресу, натиснувши на кнопку нижче:',
'button': 'Підтвердити email',
@@ -176,7 +189,7 @@ class EmailService:
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'greeting': f'سلام{", " + safe_username if safe_username else ""}!',
'subject': 'تایید آدرس ایمیل',
'intro': 'از ثبت‌نام شما سپاسگزاریم! لطفاً با کلیک روی دکمه زیر ایمیل خود را تایید کنید:',
'button': 'تایید ایمیل',
@@ -260,10 +273,13 @@ class EmailService:
full_url = f'{reset_url}?token={reset_token}'
expire_hours = settings.get_cabinet_password_reset_expire_hours()
# Escape user-provided values for HTML context
safe_username = html.escape(username) if username else None
# Localized content
texts = {
'ru': {
'greeting': f'Здравствуйте{", " + username if username else ""}!',
'greeting': f'Здравствуйте{", " + safe_username if safe_username else ""}!',
'subject': 'Сброс пароля',
'intro': 'Мы получили запрос на сброс вашего пароля. Нажмите на кнопку ниже, чтобы установить новый пароль:',
'button': 'Сбросить пароль',
@@ -273,7 +289,7 @@ class EmailService:
'regards': 'С уважением,',
},
'en': {
'greeting': f'Hello{", " + username if username else ""}!',
'greeting': f'Hello{", " + safe_username if safe_username else ""}!',
'subject': 'Reset your password',
'intro': 'We received a request to reset your password. Click the button below to set a new password:',
'button': 'Reset Password',
@@ -283,7 +299,7 @@ class EmailService:
'regards': 'Best regards,',
},
'zh': {
'greeting': f'您好{", " + username if username else ""}!',
'greeting': f'您好{", " + safe_username if safe_username else ""}!',
'subject': '重置您的密码',
'intro': '我们收到了重置您密码的请求。点击下方按钮设置新密码:',
'button': '重置密码',
@@ -293,7 +309,7 @@ class EmailService:
'regards': '此致,',
},
'ua': {
'greeting': f'Вітаємо{", " + username if username else ""}!',
'greeting': f'Вітаємо{", " + safe_username if safe_username else ""}!',
'subject': 'Скидання пароля',
'intro': 'Ми отримали запит на скидання вашого пароля. Натисніть на кнопку нижче, щоб встановити новий пароль:',
'button': 'Скинути пароль',
@@ -303,7 +319,7 @@ class EmailService:
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'greeting': f'سلام{", " + safe_username if safe_username else ""}!',
'subject': 'بازنشانی رمز عبور',
'intro': 'درخواستی برای بازنشانی رمز عبور شما دریافت شد. برای تعیین رمز جدید روی دکمه زیر بزنید:',
'button': 'بازنشانی رمز عبور',
@@ -385,9 +401,12 @@ class EmailService:
expire_minutes = settings.get_cabinet_email_change_code_expire_minutes()
# Escape user-provided values for HTML context
safe_username = html.escape(username) if username else None
texts = {
'ru': {
'greeting': f'Здравствуйте{", " + username if username else ""}!',
'greeting': f'Здравствуйте{", " + safe_username if safe_username else ""}!',
'subject': 'Код подтверждения для смены email',
'intro': 'Вы запросили смену email адреса. Используйте код ниже для подтверждения:',
'code_label': 'Ваш код подтверждения:',
@@ -396,7 +415,7 @@ class EmailService:
'regards': 'С уважением,',
},
'en': {
'greeting': f'Hello{", " + username if username else ""}!',
'greeting': f'Hello{", " + safe_username if safe_username else ""}!',
'subject': 'Email change verification code',
'intro': 'You requested to change your email address. Use the code below to confirm:',
'code_label': 'Your verification code:',
@@ -405,7 +424,7 @@ class EmailService:
'regards': 'Best regards,',
},
'zh': {
'greeting': f'您好{", " + username if username else ""}!',
'greeting': f'您好{", " + safe_username if safe_username else ""}!',
'subject': '邮箱更换验证码',
'intro': '您请求更换邮箱地址。请使用以下验证码确认:',
'code_label': '您的验证码:',
@@ -414,7 +433,7 @@ class EmailService:
'regards': '此致,',
},
'ua': {
'greeting': f'Вітаємо{", " + username if username else ""}!',
'greeting': f'Вітаємо{", " + safe_username if safe_username else ""}!',
'subject': 'Код підтвердження для зміни email',
'intro': 'Ви запросили зміну email адреси. Використовуйте код нижче для підтвердження:',
'code_label': 'Ваш код підтвердження:',
@@ -423,7 +442,7 @@ class EmailService:
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'greeting': f'سلام{", " + safe_username if safe_username else ""}!',
'subject': 'کد تایید تغییر ایمیل',
'intro': 'شما درخواست تغییر ایمیل داده‌اید. برای تایید از کد زیر استفاده کنید:',
'code_label': 'کد تایید شما:',
@@ -4,6 +4,7 @@ Service for managing email template overrides stored in the database.
Custom templates override the hardcoded defaults from email_templates.py.
"""
import html
from datetime import UTC, datetime
from typing import Any
@@ -195,7 +196,7 @@ async def get_rendered_override(
# Simple variable substitution for context vars like {username}, {verification_url}, etc.
if context:
for key, value in context.items():
body_html = body_html.replace(f'{{{key}}}', str(value))
body_html = body_html.replace(f'{{{key}}}', html.escape(str(value)))
rendered = templates._get_base_template(body_html, language)
subject = override['subject']
@@ -203,7 +204,8 @@ async def get_rendered_override(
# Also substitute in subject
if context:
for key, value in context.items():
subject = subject.replace(f'{{{key}}}', str(value))
safe_value = str(value).replace('\r', '').replace('\n', '')
subject = subject.replace(f'{{{key}}}', safe_value)
return (subject, rendered)
+395 -4
View File
@@ -62,6 +62,10 @@ class EmailNotificationTemplates:
NotificationType.PAYMENT_RECEIVED: self._payment_received_template,
NotificationType.EMAIL_VERIFICATION: self._email_verification_template,
NotificationType.PASSWORD_RESET: self._password_reset_template,
NotificationType.GUEST_SUBSCRIPTION_DELIVERED: self._guest_subscription_delivered_template,
NotificationType.GUEST_ACTIVATION_REQUIRED: self._guest_activation_required_template,
NotificationType.GUEST_GIFT_RECEIVED: self._guest_gift_received_template,
NotificationType.GUEST_CABINET_CREDENTIALS: self._guest_cabinet_credentials_template,
}
template_func = template_map.get(notification_type)
@@ -1185,8 +1189,8 @@ class EmailNotificationTemplates:
def _email_verification_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for email verification."""
username = context.get('username', '')
verification_url = context.get('verification_url', '#')
username = html.escape(context.get('username', ''))
verification_url = html.escape(context.get('verification_url', '#'))
expire_hours = context.get('expire_hours', 24)
subjects = {
@@ -1257,8 +1261,8 @@ class EmailNotificationTemplates:
def _password_reset_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for password reset."""
username = context.get('username', '')
reset_url = context.get('reset_url', '#')
username = html.escape(context.get('username', ''))
reset_url = html.escape(context.get('reset_url', '#'))
expire_hours = context.get('expire_hours', 1)
subjects = {
@@ -1327,6 +1331,393 @@ class EmailNotificationTemplates:
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
# ============================================================================
# Guest Purchase Templates
# ============================================================================
def _guest_subscription_delivered_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for guest subscription delivered notification."""
tariff_name = html.escape(context.get('tariff_name', ''))
period_days = context.get('period_days', 0)
cabinet_url = html.escape(context.get('cabinet_url', ''))
subjects = {
'ru': 'Ваша VPN подписка готова',
'en': 'Your VPN subscription is ready',
'zh': '您的VPN订阅已准备就绪',
'ua': 'Ваша VPN підписка готова',
'fa': 'اشتراک VPN شما آماده است',
}
bodies = {
'ru': f"""
<h2>Ваша VPN подписка готова!</h2>
<div class="highlight success">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Период: <strong>{period_days} дней</strong></p>
</div>
<p>Подписка активирована в вашем личном кабинете.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
""",
'en': f"""
<h2>Your VPN subscription is ready!</h2>
<div class="highlight success">
<p>Plan: <strong>{tariff_name}</strong></p>
<p>Period: <strong>{period_days} days</strong></p>
</div>
<p>Your subscription has been activated in your cabinet.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
""",
'zh': f"""
<h2>您的VPN订阅已准备就绪</h2>
<div class="highlight success">
<p>套餐: <strong>{tariff_name}</strong></p>
<p>期限: <strong>{period_days} </strong></p>
</div>
<p>订阅已在您的个人中心激活</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
""",
'ua': f"""
<h2>Ваша VPN підписка готова!</h2>
<div class="highlight success">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Період: <strong>{period_days} днів</strong></p>
</div>
<p>Підписка активована у вашому особистому кабінеті.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
""",
'fa': f"""
<h2>اشتراک VPN شما آماده است!</h2>
<div class="highlight success">
<p>طرح: <strong>{tariff_name}</strong></p>
<p>مدت: <strong>{period_days} روز</strong></p>
</div>
<p>اشتراک شما در پنل کاربری فعال شده است.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
def _guest_activation_required_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for guest purchase pending activation (user already has a subscription)."""
tariff_name = html.escape(context.get('tariff_name', ''))
period_days = context.get('period_days', 0)
success_page_url = html.escape(context.get('success_page_url', ''))
gift_message = context.get('gift_message')
is_gift = context.get('is_gift', False)
gift_block_ru = ''
gift_block_en = ''
gift_block_zh = ''
gift_block_ua = ''
gift_block_fa = ''
if is_gift and gift_message:
escaped_msg = html.escape(gift_message)
gift_block_ru = f'<div class="highlight"><p><em>Сообщение: {escaped_msg}</em></p></div>'
gift_block_en = f'<div class="highlight"><p><em>Message: {escaped_msg}</em></p></div>'
gift_block_zh = f'<div class="highlight"><p><em>留言: {escaped_msg}</em></p></div>'
gift_block_ua = f'<div class="highlight"><p><em>Повідомлення: {escaped_msg}</em></p></div>'
gift_block_fa = f'<div class="highlight"><p><em>پیام: {escaped_msg}</em></p></div>'
subjects = {
'ru': 'Требуется активация подписки',
'en': 'Subscription activation required',
'zh': '需要激活订阅',
'ua': 'Потрібна активація підписки',
'fa': 'فعال‌سازی اشتراک لازم است',
}
bodies = {
'ru': f"""
<h2>Требуется активация подписки</h2>
{gift_block_ru}
<div class="highlight">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Период: <strong>{period_days} дней</strong></p>
</div>
<p class="warning">У вас уже есть активная подписка. Активация новой заменит текущую.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">Активировать подписку</a></p>
""",
'en': f"""
<h2>Subscription activation required</h2>
{gift_block_en}
<div class="highlight">
<p>Plan: <strong>{tariff_name}</strong></p>
<p>Period: <strong>{period_days} days</strong></p>
</div>
<p class="warning">You already have an active subscription. Activating will replace your current one.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">Activate subscription</a></p>
""",
'zh': f"""
<h2>需要激活订阅</h2>
{gift_block_zh}
<div class="highlight">
<p>套餐: <strong>{tariff_name}</strong></p>
<p>期限: <strong>{period_days} </strong></p>
</div>
<p class="warning">您已有活跃订阅激活新订阅将替换当前订阅</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">激活订阅</a></p>
""",
'ua': f"""
<h2>Потрібна активація підписки</h2>
{gift_block_ua}
<div class="highlight">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Період: <strong>{period_days} днів</strong></p>
</div>
<p class="warning">У вас вже є активна підписка. Активація нової замінить поточну.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">Активувати підписку</a></p>
""",
'fa': f"""
<h2>فعالسازی اشتراک لازم است</h2>
{gift_block_fa}
<div class="highlight">
<p>طرح: <strong>{tariff_name}</strong></p>
<p>مدت: <strong>{period_days} روز</strong></p>
</div>
<p class="warning">شما از قبل اشتراک فعالی دارید. فعالسازی اشتراک جدید جایگزین فعلی خواهد شد.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">فعالسازی اشتراک</a></p>
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
def _guest_gift_received_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for gift subscription received notification."""
tariff_name = html.escape(context.get('tariff_name', ''))
period_days = context.get('period_days', 0)
gift_message = context.get('gift_message')
cabinet_password = context.get('cabinet_password')
cabinet_email = html.escape(context.get('cabinet_email', ''))
cabinet_url = html.escape(context.get('cabinet_url', ''))
# Credentials block for gift recipients who got a new cabinet account
cred_block = {'ru': '', 'en': '', 'zh': '', 'ua': '', 'fa': ''}
if cabinet_password and cabinet_email:
escaped_pw = html.escape(cabinet_password)
cred_block = {
'ru': f"""
<div class="highlight">
<p><strong>Данные для входа в личный кабинет:</strong></p>
<p>Email: <code>{cabinet_email}</code></p>
<p>Пароль: <code>{escaped_pw}</code></p>
</div>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
""",
'en': f"""
<div class="highlight">
<p><strong>Your cabinet login credentials:</strong></p>
<p>Email: <code>{cabinet_email}</code></p>
<p>Password: <code>{escaped_pw}</code></p>
</div>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
""",
'zh': f"""
<div class="highlight">
<p><strong>个人中心登录信息</strong></p>
<p>邮箱: <code>{cabinet_email}</code></p>
<p>密码: <code>{escaped_pw}</code></p>
</div>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
""",
'ua': f"""
<div class="highlight">
<p><strong>Дані для входу в особистий кабінет:</strong></p>
<p>Email: <code>{cabinet_email}</code></p>
<p>Пароль: <code>{escaped_pw}</code></p>
</div>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
""",
'fa': f"""
<div class="highlight">
<p><strong>اطلاعات ورود به پنل کاربری:</strong></p>
<p>ایمیل: <code dir="ltr">{cabinet_email}</code></p>
<p>رمز عبور: <code dir="ltr">{escaped_pw}</code></p>
</div>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
""",
}
gift_block_ru = ''
gift_block_en = ''
gift_block_zh = ''
gift_block_ua = ''
gift_block_fa = ''
if gift_message:
escaped_msg = html.escape(gift_message)
gift_block_ru = f'<div class="highlight"><p><em>Сообщение: {escaped_msg}</em></p></div>'
gift_block_en = f'<div class="highlight"><p><em>Message: {escaped_msg}</em></p></div>'
gift_block_zh = f'<div class="highlight"><p><em>留言: {escaped_msg}</em></p></div>'
gift_block_ua = f'<div class="highlight"><p><em>Повідомлення: {escaped_msg}</em></p></div>'
gift_block_fa = f'<div class="highlight"><p><em>پیام: {escaped_msg}</em></p></div>'
subjects = {
'ru': 'Вам подарили VPN подписку!',
'en': "You've been gifted a VPN subscription!",
'zh': '您收到了VPN订阅礼物!',
'ua': 'Вам подарували VPN підписку!',
'fa': 'یک اشتراک VPN به شما هدیه داده شده است!',
}
bodies = {
'ru': f"""
<h2>Вам подарили VPN подписку!</h2>
{gift_block_ru}
<div class="highlight success">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Период: <strong>{period_days} дней</strong></p>
</div>
<p>Подписка активирована в личном кабинете.</p>
{cred_block['ru']}
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
""",
'en': f"""
<h2>You've been gifted a VPN subscription!</h2>
{gift_block_en}
<div class="highlight success">
<p>Plan: <strong>{tariff_name}</strong></p>
<p>Period: <strong>{period_days} days</strong></p>
</div>
<p>Your subscription has been activated in the cabinet.</p>
{cred_block['en']}
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
""",
'zh': f"""
<h2>您收到了VPN订阅礼物</h2>
{gift_block_zh}
<div class="highlight success">
<p>套餐: <strong>{tariff_name}</strong></p>
<p>期限: <strong>{period_days} </strong></p>
</div>
<p>订阅已在个人中心激活</p>
{cred_block['zh']}
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
""",
'ua': f"""
<h2>Вам подарували VPN підписку!</h2>
{gift_block_ua}
<div class="highlight success">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Період: <strong>{period_days} днів</strong></p>
</div>
<p>Підписка активована в особистому кабінеті.</p>
{cred_block['ua']}
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
""",
'fa': f"""
<h2>یک اشتراک VPN به شما هدیه داده شده است!</h2>
{gift_block_fa}
<div class="highlight success">
<p>طرح: <strong>{tariff_name}</strong></p>
<p>مدت: <strong>{period_days} روز</strong></p>
</div>
<p>اشتراک در پنل کاربری فعال شده است.</p>
{cred_block['fa']}
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
def _guest_cabinet_credentials_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for cabinet login credentials email (sent separately from subscription)."""
cabinet_email = html.escape(context.get('cabinet_email', ''))
cabinet_password = html.escape(context.get('cabinet_password', ''))
cabinet_url = html.escape(context.get('cabinet_url', ''))
tariff_name = html.escape(context.get('tariff_name', ''))
period_days = context.get('period_days', 0)
subjects = {
'ru': 'Данные для входа в личный кабинет',
'en': 'Your cabinet login credentials',
'zh': '您的个人中心登录信息',
'ua': 'Дані для входу в особистий кабінет',
'fa': 'اطلاعات ورود به پنل کاربری',
}
bodies = {
'ru': f"""
<h2>Данные для входа в личный кабинет</h2>
<div class="highlight success">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Период: <strong>{period_days} дней</strong></p>
</div>
<div class="highlight">
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>Пароль:</strong> <code>{cabinet_password}</code></p>
</div>
<p>Сохраните эти данные для входа. Вы можете изменить пароль в настройках кабинета.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
""",
'en': f"""
<h2>Your cabinet login credentials</h2>
<div class="highlight success">
<p>Plan: <strong>{tariff_name}</strong></p>
<p>Period: <strong>{period_days} days</strong></p>
</div>
<div class="highlight">
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>Password:</strong> <code>{cabinet_password}</code></p>
</div>
<p>Save these credentials. You can change your password in cabinet settings.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
""",
'zh': f"""
<h2>您的个人中心登录信息</h2>
<div class="highlight success">
<p>套餐: <strong>{tariff_name}</strong></p>
<p>期限: <strong>{period_days} </strong></p>
</div>
<div class="highlight">
<p><strong>邮箱:</strong> <code>{cabinet_email}</code></p>
<p><strong>密码:</strong> <code>{cabinet_password}</code></p>
</div>
<p>请保存这些登录信息您可以在个人中心设置中更改密码</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
""",
'ua': f"""
<h2>Дані для входу в особистий кабінет</h2>
<div class="highlight success">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Період: <strong>{period_days} днів</strong></p>
</div>
<div class="highlight">
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>Пароль:</strong> <code>{cabinet_password}</code></p>
</div>
<p>Збережіть ці дані. Ви можете змінити пароль у налаштуваннях кабінету.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
""",
'fa': f"""
<h2>اطلاعات ورود به پنل کاربری</h2>
<div class="highlight success">
<p>طرح: <strong>{tariff_name}</strong></p>
<p>مدت: <strong>{period_days} روز</strong></p>
</div>
<div class="highlight">
<p><strong>ایمیل:</strong> <code dir="ltr">{cabinet_email}</code></p>
<p><strong>رمز عبور:</strong> <code dir="ltr">{cabinet_password}</code></p>
</div>
<p>این اطلاعات را ذخیره کنید. میتوانید رمز عبور خود را در تنظیمات پنل تغییر دهید.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
# Singleton instance
email_notification_templates = EmailNotificationTemplates()
+43
View File
@@ -0,0 +1,43 @@
"""Locale resolution utilities for multi-locale landing page text fields."""
SUPPORTED_LOCALES: tuple[str, ...] = ('ru', 'en', 'zh', 'fa')
DEFAULT_LOCALE: str = 'ru'
def resolve_locale_text(data: dict[str, str] | str | None, lang: str = DEFAULT_LOCALE) -> str:
"""Resolve a localized text dict to a single string for the given language.
Fallback chain: requested lang -> 'ru' -> 'en' -> first available value -> ''.
Accepts plain strings for backward compatibility with pre-migration data.
"""
if data is None:
return ''
if isinstance(data, str):
return data
return data.get(lang) or data.get('ru') or data.get('en') or next(iter(data.values()), '')
def ensure_locale_dict(value: dict[str, str] | str | None) -> dict[str, str]:
"""Coerce a value to a locale dict. Plain strings become ``{'ru': value}``."""
if value is None:
return {}
if isinstance(value, str):
return {'ru': value} if value else {}
return value
def validate_locale_dict(
value: dict[str, str],
*,
max_length: int | None = None,
field_name: str = 'field',
) -> dict[str, str]:
"""Validate that all keys are supported locales and values respect length limits."""
for locale, text in value.items():
if locale not in SUPPORTED_LOCALES:
raise ValueError(f'Unsupported locale "{locale}" in {field_name}. Allowed: {", ".join(SUPPORTED_LOCALES)}')
if not isinstance(text, str):
raise ValueError(f'{field_name}[{locale}] must be a string')
if max_length is not None and len(text) > max_length:
raise ValueError(f'{field_name}[{locale}] exceeds max length {max_length} (got {len(text)})')
return value
+108 -24
View File
@@ -1,12 +1,12 @@
import hashlib
import hmac
import html
import math
import os
import re
from collections import defaultdict
from datetime import time
from pathlib import Path
from typing import Literal
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
@@ -219,6 +219,7 @@ class Settings(BaseSettings):
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
REFERRAL_INVITER_BONUS_KOPEKS: int = 10000
REFERRAL_COMMISSION_PERCENT: int = 25
REFERRAL_MAX_COMMISSION_PAYMENTS: int = 0 # Макс. кол-во платежей реферала с комиссией (0 = без лимита)
REFERRAL_PROGRAM_ENABLED: bool = True
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
@@ -318,6 +319,17 @@ class Settings(BaseSettings):
TELEGRAM_STARS_RATE_RUB: float = 1.3
TELEGRAM_STARS_DISPLAY_NAME: str = 'Telegram Stars'
# Telegram Login Widget (cabinet auth page)
TELEGRAM_WIDGET_SIZE: Literal['large', 'medium', 'small'] = 'large'
TELEGRAM_WIDGET_RADIUS: int = Field(default=8, ge=0, le=20)
TELEGRAM_WIDGET_USERPIC: bool = True
TELEGRAM_WIDGET_REQUEST_ACCESS: bool = True
# Telegram Login OIDC (new system via oauth.telegram.org)
TELEGRAM_OIDC_ENABLED: bool = False
TELEGRAM_OIDC_CLIENT_ID: str = ''
TELEGRAM_OIDC_CLIENT_SECRET: str = ''
TRIBUTE_ENABLED: bool = False
TRIBUTE_API_KEY: str | None = None
TRIBUTE_DONATE_LINK: str | None = None
@@ -342,6 +354,8 @@ class Settings(BaseSettings):
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
YOOKASSA_RECURRENT_ENABLED: bool = False
YOOKASSA_RECURRENT_REQUIRED: bool = False
DISABLE_TOPUP_BUTTONS: bool = False
SUPPORT_TOPUP_ENABLED: bool = True
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
@@ -522,6 +536,18 @@ class Settings(BaseSettings):
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
# RioPay (api.riopay.online) v2.0.1
RIOPAY_ENABLED: bool = False
RIOPAY_API_TOKEN: str | None = None # x-api-token header
RIOPAY_WEBHOOK_SECRET: str | None = None # HMAC-SHA512 ключ для вебхуков (по умолчанию = API_TOKEN)
RIOPAY_DISPLAY_NAME: str = 'RioPay'
RIOPAY_CURRENCY: str = 'RUB'
RIOPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
RIOPAY_MAX_AMOUNT_KOPEKS: int = 100000000 # 1 000 000₽
RIOPAY_WEBHOOK_PATH: str = '/riopay-webhook'
RIOPAY_SUCCESS_URL: str | None = None
RIOPAY_FAIL_URL: str | None = None
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
CABINET_BUTTON_STYLE: str = ''
@@ -925,12 +951,12 @@ class Settings(BaseSettings):
def get_test_email(self) -> str | None:
"""Get test email for development/testing."""
email = (self.TEST_EMAIL or '').strip().lower()
return email if email else None
return email or None
def get_test_email_password(self) -> str | None:
"""Get test email password."""
password = (self.TEST_EMAIL_PASSWORD or '').strip()
return password if password else None
return password or None
def is_test_email(self, email: str) -> bool:
"""Check if email is the configured test email."""
@@ -1389,6 +1415,26 @@ class Settings(BaseSettings):
return value
return None
_CABINET_URL_DEFAULT = 'https://example.com/cabinet'
def get_referral_link(self, referral_code: str, bot_username: str | None = None) -> str:
"""Build a referral link pointing to the web cabinet.
Falls back to a Telegram bot deep link when CABINET_URL is not configured.
"""
from urllib.parse import quote
if not referral_code:
raise ValueError('referral_code must not be empty or None')
safe_code = quote(referral_code, safe='')
cabinet_url = (self.CABINET_URL or '').strip().rstrip('/')
if cabinet_url and cabinet_url != self._CABINET_URL_DEFAULT:
sep = '&' if '?' in cabinet_url else '?'
return f'{cabinet_url}{sep}ref={safe_code}'
username = bot_username or self.get_bot_username() or 'bot'
return f'https://t.me/{username}?start={safe_code}'
def is_deep_links_enabled(self) -> bool:
return self.ENABLE_DEEP_LINKS
@@ -1495,7 +1541,7 @@ class Settings(BaseSettings):
except (ValueError, IndexError):
continue
return packages if packages else self.get_traffic_packages()
return packages or self.get_traffic_packages()
def get_traffic_topup_price(self, gb: int | None) -> int:
"""Возвращает цену докупки для указанного количества ГБ."""
@@ -1585,7 +1631,7 @@ class Settings(BaseSettings):
def get_yookassa_display_name(self) -> str:
name = (self.YOOKASSA_DISPLAY_NAME or '').strip()
return name if name else 'YooKassa'
return name or 'YooKassa'
def is_nalogo_enabled(self) -> bool:
return self.NALOGO_ENABLED and self.NALOGO_INN is not None and self.NALOGO_PASSWORD is not None
@@ -1605,14 +1651,14 @@ class Settings(BaseSettings):
def get_cryptobot_display_name(self) -> str:
name = (self.CRYPTOBOT_DISPLAY_NAME or '').strip()
return name if name else 'CryptoBot'
return name or 'CryptoBot'
def is_heleket_enabled(self) -> bool:
return self.HELEKET_ENABLED and self.HELEKET_MERCHANT_ID is not None and self.HELEKET_API_KEY is not None
def get_heleket_display_name(self) -> str:
name = (self.HELEKET_DISPLAY_NAME or '').strip()
return name if name else 'Heleket Crypto'
return name or 'Heleket Crypto'
def is_mulenpay_enabled(self) -> bool:
return (
@@ -1650,7 +1696,7 @@ class Settings(BaseSettings):
def get_pal24_display_name(self) -> str:
name = (self.PAL24_DISPLAY_NAME or '').strip()
return name if name else 'PAL24'
return name or 'PAL24'
def is_platega_enabled(self) -> bool:
return self.PLATEGA_ENABLED and self.PLATEGA_MERCHANT_ID is not None and self.PLATEGA_SECRET is not None
@@ -1726,11 +1772,11 @@ class Settings(BaseSettings):
return info.get('title') or info.get('name') or f'Platega {method_code}'
def is_wata_enabled(self) -> bool:
return self.WATA_ENABLED and self.WATA_ACCESS_TOKEN is not None and self.WATA_TERMINAL_PUBLIC_ID is not None
return self.WATA_ENABLED and self.WATA_ACCESS_TOKEN is not None
def get_wata_display_name(self) -> str:
name = (self.WATA_DISPLAY_NAME or '').strip()
return name if name else 'Wata'
return name or 'Wata'
def is_cloudpayments_enabled(self) -> bool:
return (
@@ -1741,7 +1787,7 @@ class Settings(BaseSettings):
def get_cloudpayments_display_name(self) -> str:
name = (self.CLOUDPAYMENTS_DISPLAY_NAME or '').strip()
return name if name else 'CloudPayments'
return name or 'CloudPayments'
def is_freekassa_enabled(self) -> bool:
return (
@@ -1754,7 +1800,7 @@ class Settings(BaseSettings):
def get_freekassa_display_name(self) -> str:
name = (self.FREEKASSA_DISPLAY_NAME or '').strip()
return name if name else 'Freekassa'
return name or 'Freekassa'
def get_freekassa_display_name_html(self) -> str:
return html.escape(self.get_freekassa_display_name())
@@ -1764,7 +1810,7 @@ class Settings(BaseSettings):
def get_freekassa_sbp_display_name(self) -> str:
name = (self.FREEKASSA_SBP_DISPLAY_NAME or '').strip()
return name if name else 'СБП (QR код)'
return name or 'СБП (QR код)'
def get_freekassa_sbp_display_name_html(self) -> str:
return html.escape(self.get_freekassa_sbp_display_name())
@@ -1774,7 +1820,7 @@ class Settings(BaseSettings):
def get_freekassa_card_display_name(self) -> str:
name = (self.FREEKASSA_CARD_DISPLAY_NAME or '').strip()
return name if name else 'Карта РФ'
return name or 'Карта РФ'
def get_freekassa_card_display_name_html(self) -> str:
return html.escape(self.get_freekassa_card_display_name())
@@ -1789,11 +1835,21 @@ class Settings(BaseSettings):
def get_kassa_ai_display_name(self) -> str:
name = (self.KASSA_AI_DISPLAY_NAME or '').strip()
return name if name else 'KassaAI'
return name or 'KassaAI'
def get_kassa_ai_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_display_name())
def is_riopay_enabled(self) -> bool:
return self.RIOPAY_ENABLED and self.RIOPAY_API_TOKEN is not None
def get_riopay_display_name(self) -> str:
name = (self.RIOPAY_DISPLAY_NAME or '').strip()
return name or 'RioPay'
def get_riopay_display_name_html(self) -> str:
return html.escape(self.get_riopay_display_name())
def is_payment_verification_auto_check_enabled(self) -> bool:
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
@@ -1889,7 +1945,7 @@ class Settings(BaseSettings):
'windows': ((self.HAPP_DOWNLOAD_LINK_WINDOWS or '').strip() or (self.HAPP_DOWNLOAD_LINK_PC or '').strip()),
}
link = links.get(platform_key)
return link if link else None
return link or None
def is_maintenance_mode(self) -> bool:
return self.MAINTENANCE_MODE
@@ -1977,7 +2033,7 @@ class Settings(BaseSettings):
# т.к. в режиме classic цена складывается из серверов/трафика/устройств)
periods = sorted(allowed_periods)
return periods if periods else [30, 90, 180]
return periods or [30, 90, 180]
def get_available_renewal_periods(self) -> list[int]:
"""
@@ -2002,7 +2058,7 @@ class Settings(BaseSettings):
# Возвращаем только разрешённые периоды (без фильтрации по цене)
periods = sorted(allowed_periods)
return periods if periods else [30, 90, 180]
return periods or [30, 90, 180]
def get_configured_subscription_periods(self) -> list[int]:
"""
@@ -2067,7 +2123,7 @@ class Settings(BaseSettings):
def get_telegram_stars_display_name(self) -> str:
name = (self.TELEGRAM_STARS_DISPLAY_NAME or '').strip()
return name if name else 'Telegram Stars'
return name or 'Telegram Stars'
def stars_to_rubles(self, stars: int) -> float:
return stars * self.get_stars_rate()
@@ -2076,7 +2132,7 @@ class Settings(BaseSettings):
rate = self.get_stars_rate()
if rate <= 0:
raise ValueError('Stars rate must be positive')
return max(1, math.ceil(rubles / rate))
return max(1, round(rubles / rate))
def get_admin_notifications_chat_id(self) -> int | None:
if not self.ADMIN_NOTIFICATIONS_CHAT_ID:
@@ -2104,7 +2160,7 @@ class Settings(BaseSettings):
def get_backup_archive_password(self) -> str | None:
password = (self.BACKUP_ARCHIVE_PASSWORD or '').strip()
return password if password else None
return password or None
# === Log Rotation Methods ===
@@ -2185,7 +2241,7 @@ class Settings(BaseSettings):
except ValueError:
continue
return packages if packages else self._get_fallback_traffic_packages()
return packages or self._get_fallback_traffic_packages()
except Exception as e:
logger.warning('ERROR PARSING CONFIG', error=e)
@@ -2318,13 +2374,13 @@ class Settings(BaseSettings):
if contact.startswith(('t.me/', 'telegram.me/', 'telegram.dog/')):
url = self.get_support_contact_url()
return url if url else contact
return url or contact
contact_without_prefix = contact.lstrip('@')
if '.' in contact_without_prefix:
url = self.get_support_contact_url()
return url if url else contact
return url or contact
if re.fullmatch(r'[A-Za-z0-9_]{3,}', contact_without_prefix):
return f'@{contact_without_prefix}'
@@ -2468,6 +2524,15 @@ class Settings(BaseSettings):
def get_cabinet_jwt_secret(self) -> str:
if self.CABINET_JWT_SECRET:
return self.CABINET_JWT_SECRET
import warnings
warnings.warn(
'CABINET_JWT_SECRET is not set, falling back to BOT_TOKEN. '
'Set CABINET_JWT_SECRET to a unique secret in production: '
'python -c "import secrets; print(secrets.token_urlsafe(64))"',
UserWarning,
stacklevel=2,
)
return self.BOT_TOKEN
def get_cabinet_access_token_expire_minutes(self) -> int:
@@ -2635,6 +2700,25 @@ PERIOD_PRICES: dict[int, int] = {}
refresh_period_prices()
def _build_classic_period_prices() -> dict[int, int]:
"""Build classic-mode period prices directly from PRICE_*_DAYS settings.
Unlike PERIOD_PRICES (which may use DB tariff prices in tariffs mode),
this always reflects the env/settings values the canonical prices for
classic (non-tariff) subscriptions.
"""
return {days: getattr(settings, field_name, 0) for days, field_name in _PERIOD_PRICE_FIELDS.items()}
CLASSIC_PERIOD_PRICES: dict[int, int] = _build_classic_period_prices()
def refresh_classic_period_prices() -> None:
"""Rebuild CLASSIC_PERIOD_PRICES from current settings."""
CLASSIC_PERIOD_PRICES.clear()
CLASSIC_PERIOD_PRICES.update(_build_classic_period_prices())
def get_traffic_prices() -> dict[int, int]:
packages = settings.get_traffic_packages()
return {package['gb']: package['price'] for package in packages}
+3
View File
@@ -5,6 +5,7 @@ from sqlalchemy import and_, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.crud.transaction import REAL_PAYMENT_METHODS
from app.database.models import (
AdvertisingCampaign,
AdvertisingCampaignRegistration,
@@ -268,11 +269,13 @@ async def get_campaign_statistics(
)
subscription_bonuses_issued = subscription_count_result.scalar() or 0
# Only count real deposits (exclude promo bonuses, wheel prizes, admin top-ups)
deposits_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed.is_(True),
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
)
)
deposits_total = deposits_result.scalar() or 0
+1 -1
View File
@@ -18,7 +18,7 @@ logger = structlog.get_logger(__name__)
async def create_cloudpayments_payment(
db: AsyncSession,
*,
user_id: int,
user_id: int | None,
invoice_id: str,
amount_kopeks: int,
description: str | None = None,
+12 -4
View File
@@ -13,7 +13,7 @@ logger = structlog.get_logger(__name__)
async def create_cryptobot_payment(
db: AsyncSession,
user_id: int,
user_id: int | None,
invoice_id: str,
amount: str,
asset: str,
@@ -73,7 +73,12 @@ async def get_cryptobot_payment_by_id_for_update(db: AsyncSession, payment_id: i
async def update_cryptobot_payment_status(
db: AsyncSession, invoice_id: str, status: str, paid_at: datetime | None = None
db: AsyncSession,
invoice_id: str,
status: str,
paid_at: datetime | None = None,
*,
commit: bool = True,
) -> CryptoBotPayment | None:
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
@@ -86,8 +91,11 @@ async def update_cryptobot_payment_status(
if status == 'paid' and paid_at:
payment.paid_at = paid_at
await db.commit()
await db.refresh(payment)
if commit:
await db.commit()
await db.refresh(payment)
else:
await db.flush()
logger.info('Обновлен статус CryptoBot платежа', invoice_id=invoice_id, status=status)
return payment
+3 -4
View File
@@ -1,6 +1,5 @@
"""CRUD операции для платежей Freekassa."""
import json
from datetime import UTC, datetime
import structlog
@@ -16,14 +15,14 @@ logger = structlog.get_logger(__name__)
async def create_freekassa_payment(
db: AsyncSession,
*,
user_id: int,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
expires_at: datetime | None = None,
metadata_json: str | None = None,
metadata_json: dict | None = None,
) -> FreekassaPayment:
"""Создает запись о платеже Freekassa."""
payment = FreekassaPayment(
@@ -34,7 +33,7 @@ async def create_freekassa_payment(
description=description,
payment_url=payment_url,
expires_at=expires_at,
metadata_json=json.loads(metadata_json) if metadata_json else None,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
+1 -1
View File
@@ -15,7 +15,7 @@ logger = structlog.get_logger(__name__)
async def create_heleket_payment(
db: AsyncSession,
*,
user_id: int,
user_id: int | None,
uuid: str,
order_id: str,
amount: str,
+3 -4
View File
@@ -1,6 +1,5 @@
"""CRUD операции для платежей KassaAI."""
import json
from datetime import UTC, datetime
import structlog
@@ -16,7 +15,7 @@ logger = structlog.get_logger(__name__)
async def create_kassa_ai_payment(
db: AsyncSession,
*,
user_id: int,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
@@ -24,7 +23,7 @@ async def create_kassa_ai_payment(
payment_url: str | None = None,
payment_system_id: int | None = None,
expires_at: datetime | None = None,
metadata_json: str | None = None,
metadata_json: dict | None = None,
) -> KassaAiPayment:
"""Создает запись о платеже KassaAI."""
payment = KassaAiPayment(
@@ -36,7 +35,7 @@ async def create_kassa_ai_payment(
payment_url=payment_url,
payment_system_id=payment_system_id,
expires_at=expires_at,
metadata_json=json.loads(metadata_json) if metadata_json else None,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
+256
View File
@@ -0,0 +1,256 @@
import secrets
import structlog
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage
logger = structlog.get_logger(__name__)
async def get_landing_by_slug(db: AsyncSession, slug: str) -> LandingPage | None:
"""Get a landing page by its slug."""
result = await db.execute(select(LandingPage).where(LandingPage.slug == slug))
return result.scalars().first()
async def get_landing_by_id(db: AsyncSession, landing_id: int) -> LandingPage | None:
"""Get a landing page by its ID."""
result = await db.execute(select(LandingPage).where(LandingPage.id == landing_id))
return result.scalars().first()
async def get_active_landing_by_slug(db: AsyncSession, slug: str) -> LandingPage | None:
"""Get an active landing page by its slug."""
result = await db.execute(
select(LandingPage).where(
LandingPage.slug == slug,
LandingPage.is_active.is_(True),
)
)
return result.scalars().first()
async def get_all_landings(db: AsyncSession) -> list[LandingPage]:
"""Get all landing pages ordered by display_order."""
result = await db.execute(select(LandingPage).order_by(LandingPage.display_order, LandingPage.id))
return list(result.scalars().all())
async def create_landing(db: AsyncSession, **kwargs) -> LandingPage:
"""Create a new landing page."""
landing = LandingPage(**kwargs)
db.add(landing)
await db.flush()
await db.commit()
await db.refresh(landing)
logger.info(
'Created landing page',
slug=landing.slug,
landing_id=landing.id,
)
return landing
_LANDING_UPDATABLE_FIELDS = frozenset(
{
'slug',
'title',
'subtitle',
'is_active',
'features',
'footer_text',
'allowed_tariff_ids',
'allowed_periods',
'payment_methods',
'gift_enabled',
'custom_css',
'meta_title',
'meta_description',
'display_order',
'discount_percent',
'discount_overrides',
'discount_starts_at',
'discount_ends_at',
'discount_badge_text',
'background_config',
}
)
async def update_landing(db: AsyncSession, landing_id: int, data: dict) -> LandingPage | None:
"""Update a landing page by ID. Returns None if not found."""
landing = await get_landing_by_id(db, landing_id)
if landing is None:
return None
for key, value in data.items():
if key in _LANDING_UPDATABLE_FIELDS:
setattr(landing, key, value)
await db.commit()
await db.refresh(landing)
logger.info(
'Updated landing page',
landing_id=landing.id,
slug=landing.slug,
updated_fields=list(data.keys()),
)
return landing
async def delete_landing(db: AsyncSession, landing_id: int) -> bool:
"""Delete a landing page by ID. Returns True if deleted."""
landing = await get_landing_by_id(db, landing_id)
if landing is None:
return False
await db.delete(landing)
await db.commit()
logger.info(
'Deleted landing page',
landing_id=landing_id,
slug=landing.slug,
)
return True
async def update_landing_order(db: AsyncSession, landing_ids: list[int]) -> None:
"""Set display_order for landing pages based on position in list."""
for order, landing_id in enumerate(landing_ids):
await db.execute(update(LandingPage).where(LandingPage.id == landing_id).values(display_order=order))
await db.commit()
logger.info('Updated landing page order', landing_ids=landing_ids)
def generate_purchase_token() -> str:
"""Generate a cryptographically secure purchase token."""
return secrets.token_urlsafe(48)
async def create_guest_purchase(db: AsyncSession, *, commit: bool = True, **kwargs) -> GuestPurchase:
"""Create a new guest purchase with an auto-generated token."""
if 'token' not in kwargs:
kwargs['token'] = generate_purchase_token()
purchase = GuestPurchase(**kwargs)
db.add(purchase)
await db.flush()
if commit:
await db.commit()
await db.refresh(purchase)
logger.info(
'Created guest purchase',
purchase_id=purchase.id,
token_prefix=purchase.token[:5],
status=purchase.status,
landing_id=purchase.landing_id,
)
return purchase
async def get_purchase_by_token(db: AsyncSession, token: str) -> GuestPurchase | None:
"""Get a guest purchase by its token."""
result = await db.execute(select(GuestPurchase).where(GuestPurchase.token == token))
return result.scalars().first()
_PURCHASE_UPDATABLE_FIELDS = frozenset(
{
'payment_id',
'paid_at',
'delivered_at',
'subscription_url',
'subscription_crypto_link',
'user_id',
}
)
async def update_purchase_status(
db: AsyncSession,
token: str,
status: GuestPurchaseStatus | str,
*,
commit: bool = True,
**extra_fields,
) -> GuestPurchase | None:
"""Update the status of a guest purchase and optional extra fields."""
purchase = await get_purchase_by_token(db, token)
if purchase is None:
return None
old_status = purchase.status
purchase.status = status.value if isinstance(status, GuestPurchaseStatus) else status
for key, value in extra_fields.items():
if key not in _PURCHASE_UPDATABLE_FIELDS:
logger.warning('Ignoring disallowed field in purchase update', field=key)
continue
setattr(purchase, key, value)
if commit:
await db.commit()
await db.refresh(purchase)
else:
await db.flush()
logger.info(
'Updated guest purchase status',
purchase_id=purchase.id,
token_prefix=token[:5],
old_status=old_status,
new_status=purchase.status,
)
return purchase
async def get_landing_purchase_stats(db: AsyncSession, landing_id: int) -> dict:
"""Get purchase counts grouped by status for a landing page."""
result = await db.execute(
select(
GuestPurchase.status,
func.count(GuestPurchase.id),
)
.where(GuestPurchase.landing_id == landing_id)
.group_by(GuestPurchase.status)
)
rows = result.all()
stats = {s.value: 0 for s in GuestPurchaseStatus}
stats['total'] = 0
for status_value, count in rows:
stats[status_value] = count
stats['total'] += count
return stats
async def get_all_landing_purchase_stats(db: AsyncSession) -> dict[int, dict]:
"""Get purchase counts grouped by landing_id and status in a single query.
Returns a dict mapping landing_id -> {status: count, 'total': count}.
"""
result = await db.execute(
select(
GuestPurchase.landing_id,
GuestPurchase.status,
func.count(GuestPurchase.id),
)
.where(GuestPurchase.landing_id.is_not(None))
.group_by(GuestPurchase.landing_id, GuestPurchase.status)
)
rows = result.all()
all_stats: dict[int, dict] = {}
for landing_id, status_value, count in rows:
if landing_id not in all_stats:
stats = {s.value: 0 for s in GuestPurchaseStatus}
stats['total'] = 0
all_stats[landing_id] = stats
all_stats[landing_id][status_value] = count
all_stats[landing_id]['total'] += count
return all_stats
+1 -1
View File
@@ -14,7 +14,7 @@ logger = structlog.get_logger(__name__)
async def create_mulenpay_payment(
db: AsyncSession,
*,
user_id: int,
user_id: int | None,
amount_kopeks: int,
uuid: str,
description: str,
+3 -2
View File
@@ -48,9 +48,10 @@ async def record_notification(
await db.commit()
async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
async def clear_notifications(db: AsyncSession, subscription_id: int, *, commit: bool = True) -> None:
await db.execute(delete(SentNotification).where(SentNotification.subscription_id == subscription_id))
await db.commit()
if commit:
await db.commit()
async def clear_notification_by_type(
+1 -1
View File
@@ -18,7 +18,7 @@ logger = structlog.get_logger(__name__)
async def create_pal24_payment(
db: AsyncSession,
*,
user_id: int,
user_id: int | None,
bill_id: str,
amount_kopeks: int,
description: str | None,
+1 -1
View File
@@ -18,7 +18,7 @@ logger = structlog.get_logger(__name__)
async def create_platega_payment(
db: AsyncSession,
*,
user_id: int,
user_id: int | None,
amount_kopeks: int,
currency: str,
description: str | None,
+5 -4
View File
@@ -97,9 +97,9 @@ async def create_promo_group(
) -> PromoGroup:
normalized_period_discounts = _normalize_period_discounts(period_discounts)
auto_assign_total_spent_kopeks = (
max(0, auto_assign_total_spent_kopeks) if auto_assign_total_spent_kopeks is not None else None
)
if auto_assign_total_spent_kopeks is not None:
value = max(0, auto_assign_total_spent_kopeks)
auto_assign_total_spent_kopeks = value if value > 0 else None
existing_default = await get_default_promo_group(db)
should_be_default = existing_default is None or is_default
@@ -168,7 +168,8 @@ async def update_promo_group(
normalized_period_discounts = _normalize_period_discounts(period_discounts)
group.period_discounts = normalized_period_discounts or None
if auto_assign_total_spent_kopeks is not None:
group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks)
value = max(0, auto_assign_total_spent_kopeks)
group.auto_assign_total_spent_kopeks = value if value > 0 else None
if apply_discounts_to_addons is not None:
group.apply_discounts_to_addons = bool(apply_discounts_to_addons)
+2
View File
@@ -43,6 +43,8 @@ async def log_promo_offer_action(
except Exception:
logger.exception('Failed to commit promo offer log entry')
raise
else:
await db.flush()
return entry
+14
View File
@@ -50,6 +50,20 @@ async def create_referral_earning(
return earning
async def get_commission_payment_count(db: AsyncSession, referrer_id: int, referral_id: int) -> int:
"""Подсчитать количество комиссионных начислений реферера за платежи конкретного реферала."""
result = await db.execute(
select(func.count(ReferralEarning.id)).where(
and_(
ReferralEarning.user_id == referrer_id,
ReferralEarning.referral_id == referral_id,
ReferralEarning.reason == 'referral_commission_topup',
)
)
)
return result.scalar() or 0
async def get_referral_earnings_by_user(
db: AsyncSession, user_id: int, limit: int = 50, offset: int = 0
) -> list[ReferralEarning]:
+132
View File
@@ -0,0 +1,132 @@
"""CRUD операции для платежей RioPay."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import RioPayPayment
logger = structlog.get_logger(__name__)
async def create_riopay_payment(
db: AsyncSession,
*,
user_id: int,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
riopay_order_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> RioPayPayment:
"""Создает запись о платеже RioPay."""
payment = RioPayPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
riopay_order_id=riopay_order_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('Создан платеж RioPay', order_id=order_id, user_id=user_id)
return payment
async def get_riopay_payment_by_order_id(db: AsyncSession, order_id: str) -> RioPayPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(RioPayPayment).where(RioPayPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_riopay_payment_by_riopay_order_id(db: AsyncSession, riopay_order_id: str) -> RioPayPayment | None:
"""Получает платеж по ID от RioPay (UUID)."""
result = await db.execute(select(RioPayPayment).where(RioPayPayment.riopay_order_id == riopay_order_id))
return result.scalar_one_or_none()
async def get_riopay_payment_by_id(db: AsyncSession, payment_id: int) -> RioPayPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(RioPayPayment).where(RioPayPayment.id == payment_id))
return result.scalar_one_or_none()
async def update_riopay_payment_status(
db: AsyncSession,
payment: RioPayPayment,
*,
status: str,
is_paid: bool | None = None,
riopay_order_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> RioPayPayment:
"""Обновляет статус платежа."""
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 riopay_order_id:
payment.riopay_order_id = riopay_order_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload:
payment.callback_payload = callback_payload
if transaction_id:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен статус платежа RioPay',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_riopay_payments(db: AsyncSession, user_id: int) -> list[RioPayPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(RioPayPayment).where(
RioPayPayment.user_id == user_id,
RioPayPayment.status == 'pending',
RioPayPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_riopay_payments(
db: AsyncSession,
) -> list[RioPayPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(RioPayPayment).where(
RioPayPayment.status == 'pending',
RioPayPayment.is_paid == False,
RioPayPayment.expires_at < now,
)
)
return list(result.scalars().all())
+193
View File
@@ -0,0 +1,193 @@
from datetime import UTC, datetime
import structlog
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SavedPaymentMethod
logger = structlog.get_logger(__name__)
async def create_saved_payment_method(
db: AsyncSession,
user_id: int,
yookassa_payment_method_id: str,
method_type: str = 'bank_card',
card_first6: str | None = None,
card_last4: str | None = None,
card_type: str | None = None,
card_expiry_month: str | None = None,
card_expiry_year: str | None = None,
title: str | None = None,
) -> SavedPaymentMethod | None:
"""Создаёт или реактивирует сохранённый метод оплаты."""
# Проверяем, есть ли уже такой метод (включая деактивированные)
result = await db.execute(
update(SavedPaymentMethod)
.where(
SavedPaymentMethod.yookassa_payment_method_id == yookassa_payment_method_id,
SavedPaymentMethod.user_id == user_id,
)
.values(
is_active=True,
method_type=method_type,
card_first6=card_first6,
card_last4=card_last4,
card_type=card_type,
card_expiry_month=card_expiry_month,
card_expiry_year=card_expiry_year,
title=title,
updated_at=datetime.now(UTC),
)
.returning(SavedPaymentMethod)
)
reactivated = result.scalar_one_or_none()
if reactivated:
await db.commit()
logger.info(
'Реактивирован сохранённый метод оплаты',
saved_method_id=reactivated.id,
user_id=user_id,
method_type=method_type,
card_last4=card_last4,
)
return reactivated
method = SavedPaymentMethod(
user_id=user_id,
yookassa_payment_method_id=yookassa_payment_method_id,
method_type=method_type,
card_first6=card_first6,
card_last4=card_last4,
card_type=card_type,
card_expiry_month=card_expiry_month,
card_expiry_year=card_expiry_year,
title=title,
)
db.add(method)
try:
await db.commit()
except IntegrityError as e:
await db.rollback()
logger.error(
'Ошибка создания сохранённого метода оплаты',
yookassa_payment_method_id=yookassa_payment_method_id,
user_id=user_id,
e=e,
)
return None
await db.refresh(method)
logger.info(
'Создан сохранённый метод оплаты',
saved_method_id=method.id,
user_id=user_id,
method_type=method_type,
card_last4=card_last4,
)
return method
async def get_active_payment_methods_by_user(
db: AsyncSession,
user_id: int,
) -> list[SavedPaymentMethod]:
"""Получить все активные сохранённые методы оплаты пользователя."""
result = await db.execute(
select(SavedPaymentMethod)
.where(
SavedPaymentMethod.user_id == user_id,
SavedPaymentMethod.is_active == True,
)
.order_by(SavedPaymentMethod.created_at.desc())
)
return list(result.scalars().all())
async def get_user_ids_with_active_payment_methods(
db: AsyncSession,
user_ids: list[int],
) -> set[int]:
"""Вернуть подмножество user_ids, у которых есть хотя бы один активный метод оплаты."""
if not user_ids:
return set()
result = await db.execute(
select(SavedPaymentMethod.user_id)
.where(
SavedPaymentMethod.user_id.in_(user_ids),
SavedPaymentMethod.is_active == True,
)
.distinct()
)
return set(result.scalars().all())
async def get_payment_method_by_yookassa_id(
db: AsyncSession,
yookassa_payment_method_id: str,
include_inactive: bool = False,
) -> SavedPaymentMethod | None:
"""Найти сохранённый метод по YooKassa payment_method.id."""
query = select(SavedPaymentMethod).where(
SavedPaymentMethod.yookassa_payment_method_id == yookassa_payment_method_id,
)
if not include_inactive:
query = query.where(SavedPaymentMethod.is_active == True)
result = await db.execute(query)
return result.scalar_one_or_none()
async def deactivate_payment_method(
db: AsyncSession,
saved_method_id: int,
user_id: int,
) -> bool:
"""Деактивировать (soft-delete) сохранённый метод оплаты."""
result = await db.execute(
update(SavedPaymentMethod)
.where(
SavedPaymentMethod.id == saved_method_id,
SavedPaymentMethod.user_id == user_id,
SavedPaymentMethod.is_active == True,
)
.values(is_active=False, updated_at=datetime.now(UTC))
)
await db.commit()
if result.rowcount > 0:
logger.info(
'Метод оплаты деактивирован',
saved_method_id=saved_method_id,
user_id=user_id,
)
return True
return False
async def deactivate_all_user_payment_methods(
db: AsyncSession,
user_id: int,
) -> int:
"""Деактивировать все методы оплаты пользователя. Возвращает количество деактивированных."""
result = await db.execute(
update(SavedPaymentMethod)
.where(
SavedPaymentMethod.user_id == user_id,
SavedPaymentMethod.is_active == True,
)
.values(is_active=False, updated_at=datetime.now(UTC))
)
await db.commit()
if result.rowcount > 0:
logger.info(
'Все методы оплаты пользователя деактивированы',
user_id=user_id,
count=result.rowcount,
)
return result.rowcount
+1 -215
View File
@@ -306,9 +306,8 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
await create_server_squad(
db=db,
squad_uuid=squad_uuid,
display_name=_generate_display_name(original_name),
display_name=original_name,
original_name=original_name,
country_code=_extract_country_code(original_name),
price_kopeks=1000,
is_available=False,
)
@@ -482,219 +481,6 @@ async def get_random_trial_squad_uuid(
return None
def _generate_display_name(original_name: str) -> str:
"""Генерирует отображаемое название сервера на основе оригинального имени."""
country_names = {
# Европа
'NL': '🇳🇱 Нидерланды',
'DE': '🇩🇪 Германия',
'FR': '🇫🇷 Франция',
'GB': '🇬🇧 Великобритания',
'UK': '🇬🇧 Великобритания',
'IT': '🇮🇹 Италия',
'ES': '🇪🇸 Испания',
'PT': '🇵🇹 Португалия',
'PL': '🇵🇱 Польша',
'CZ': '🇨🇿 Чехия',
'AT': '🇦🇹 Австрия',
'CH': '🇨🇭 Швейцария',
'SE': '🇸🇪 Швеция',
'NO': '🇳🇴 Норвегия',
'FI': '🇫🇮 Финляндия',
'DK': '🇩🇰 Дания',
'BE': '🇧🇪 Бельгия',
'IE': '🇮🇪 Ирландия',
'RO': '🇷🇴 Румыния',
'BG': '🇧🇬 Болгария',
'HU': '🇭🇺 Венгрия',
'GR': '🇬🇷 Греция',
'LV': '🇱🇻 Латвия',
'LT': '🇱🇹 Литва',
'EE': '🇪🇪 Эстония',
'SK': '🇸🇰 Словакия',
'SI': '🇸🇮 Словения',
'HR': '🇭🇷 Хорватия',
'RS': '🇷🇸 Сербия',
'UA': '🇺🇦 Украина',
'MD': '🇲🇩 Молдова',
'BY': '🇧🇾 Беларусь',
'LU': '🇱🇺 Люксембург',
# СНГ и Азия
'RU': '🇷🇺 Россия',
'KZ': '🇰🇿 Казахстан',
'UZ': '🇺🇿 Узбекистан',
'GE': '🇬🇪 Грузия',
'AM': '🇦🇲 Армения',
'AZ': '🇦🇿 Азербайджан',
# Америка
'US': '🇺🇸 США',
'CA': '🇨🇦 Канада',
'MX': '🇲🇽 Мексика',
'BR': '🇧🇷 Бразилия',
'AR': '🇦🇷 Аргентина',
'CL': '🇨🇱 Чили',
'CO': '🇨🇴 Колумбия',
# Азия
'JP': '🇯🇵 Япония',
'KR': '🇰🇷 Южная Корея',
'CN': '🇨🇳 Китай',
'HK': '🇭🇰 Гонконг',
'TW': '🇹🇼 Тайвань',
'SG': '🇸🇬 Сингапур',
'TH': '🇹🇭 Таиланд',
'VN': '🇻🇳 Вьетнам',
'MY': '🇲🇾 Малайзия',
'ID': '🇮🇩 Индонезия',
'PH': '🇵🇭 Филиппины',
'IN': '🇮🇳 Индия',
'PK': '🇵🇰 Пакистан',
# Ближний Восток
'IL': '🇮🇱 Израиль',
'TR': '🇹🇷 Турция',
'AE': '🇦🇪 ОАЭ',
'SA': '🇸🇦 Саудовская Аравия',
'QA': '🇶🇦 Катар',
'BH': '🇧🇭 Бахрейн',
'KW': '🇰🇼 Кувейт',
# Океания
'AU': '🇦🇺 Австралия',
'NZ': '🇳🇿 Новая Зеландия',
# Африка
'ZA': '🇿🇦 ЮАР',
'EG': '🇪🇬 Египет',
'NG': '🇳🇬 Нигерия',
'KE': '🇰🇪 Кения',
}
name_upper = original_name.upper()
# Сначала ищем код как отдельный элемент (через - или _)
for code, display_name in country_names.items():
if f'-{code}' in name_upper or f'_{code}' in name_upper:
return display_name
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
return display_name
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
return display_name
if name_upper == code:
return display_name
# Потом ищем просто вхождение кода
for code, display_name in country_names.items():
if code in name_upper:
return display_name
return f'🌍 {original_name}'
def _extract_country_code(original_name: str) -> str | None:
"""Извлекает код страны из оригинального названия."""
# Полный список кодов стран
codes = [
# Европа
'NL',
'DE',
'FR',
'GB',
'UK',
'IT',
'ES',
'PT',
'PL',
'CZ',
'AT',
'CH',
'SE',
'NO',
'FI',
'DK',
'BE',
'IE',
'RO',
'BG',
'HU',
'GR',
'LV',
'LT',
'EE',
'SK',
'SI',
'HR',
'RS',
'UA',
'MD',
'BY',
'LU',
# СНГ
'RU',
'KZ',
'UZ',
'GE',
'AM',
'AZ',
# Америка
'US',
'CA',
'MX',
'BR',
'AR',
'CL',
'CO',
# Азия
'JP',
'KR',
'CN',
'HK',
'TW',
'SG',
'TH',
'VN',
'MY',
'ID',
'PH',
'IN',
'PK',
# Ближний Восток
'IL',
'TR',
'AE',
'SA',
'QA',
'BH',
'KW',
# Океания
'AU',
'NZ',
# Африка
'ZA',
'EG',
'NG',
'KE',
]
name_upper = original_name.upper()
# Сначала ищем код как отдельный элемент
for code in codes:
if f'-{code}' in name_upper or f'_{code}' in name_upper:
return code
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
return code
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
return code
if name_upper == code:
return code
# Потом просто ищем вхождение
for code in codes:
if code in name_upper:
return code
return None
async def get_server_statistics(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(ServerSquad.id)))
total_servers = total_result.scalar()
+81 -269
View File
@@ -18,10 +18,9 @@ from app.database.models import (
Transaction,
TransactionType,
User,
UserPromoGroup,
UserStatus,
)
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
from app.utils.pricing_utils import calculate_months_from_days
from app.utils.timezone import format_local_datetime
@@ -38,6 +37,31 @@ def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
return elapsed < _WEBHOOK_GUARD_SECONDS
def calc_device_limit_on_tariff_switch(
current_device_limit: int | None,
old_tariff_device_limit: int | None,
new_tariff_device_limit: int | None,
max_device_limit: int | None = None,
) -> int:
"""Calculate device_limit preserving extra purchased devices when switching tariffs.
Extra devices = current_device_limit - old_tariff_device_limit (clamped to 0).
Result = new_tariff_device_limit + extra_devices, capped at max_device_limit.
"""
old_base = old_tariff_device_limit if old_tariff_device_limit is not None else 0
current = current_device_limit if current_device_limit is not None else old_base
extra = max(0, current - old_base)
new_base = new_tariff_device_limit if new_tariff_device_limit is not None else 1
total = new_base + extra
effective_max = max_device_limit or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
if effective_max and total > effective_max:
total = effective_max
return total
def is_active_paid_subscription(subscription: Subscription | None) -> bool:
"""Return True if subscription is active, paid (non-trial), and not expired."""
if not subscription:
@@ -190,6 +214,7 @@ async def create_paid_subscription(
update_server_counters: bool = False,
is_trial: bool = False,
tariff_id: int | None = None,
commit: bool = True,
) -> Subscription:
end_date = datetime.now(UTC) + timedelta(days=duration_days)
@@ -211,8 +236,11 @@ async def create_paid_subscription(
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
if commit:
await db.commit()
await db.refresh(subscription)
else:
await db.flush()
logger.info(
'💎 Создана платная подписка для пользователя ID: статус',
@@ -265,6 +293,7 @@ async def replace_subscription(
autopay_enabled: bool | None = None,
autopay_days_before: int | None = None,
update_server_counters: bool = False,
commit: bool = True,
) -> Subscription:
"""Перезаписывает параметры существующей подписки пользователя."""
@@ -297,12 +326,15 @@ async def replace_subscription(
subscription.autopay_days_before = new_autopay_days_before
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription)
if commit:
await db.commit()
await db.refresh(subscription)
else:
await db.flush()
# Очищаем старые записи об отправленных уведомлениях при замене подписки
# (аналогично extend_subscription), чтобы новые уведомления отправлялись корректно
await clear_notifications(db, subscription.id)
await clear_notifications(db, subscription.id, commit=commit)
if update_server_counters:
try:
@@ -349,6 +381,7 @@ async def extend_subscription(
traffic_limit_gb: int | None = None,
device_limit: int | None = None,
connected_squads: list[str] | None = None,
commit: bool = True,
) -> Subscription:
"""Продлевает подписку на указанное количество дней.
@@ -381,6 +414,7 @@ async def extend_subscription(
was_expired = subscription.status in (
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.LIMITED.value,
) or (subscription.end_date is not None and subscription.end_date <= current_time)
if is_tariff_change:
@@ -437,6 +471,7 @@ async def extend_subscription(
if days > 0 and subscription.status in (
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.LIMITED.value,
):
previous_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
@@ -559,9 +594,13 @@ async def extend_subscription(
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription)
await clear_notifications(db, subscription.id)
if commit:
await db.commit()
await db.refresh(subscription, ['tariff'])
else:
await db.flush()
await clear_notifications(db, subscription.id, commit=commit)
logger.info('✅ Подписка продлена до', end_date=subscription.end_date)
logger.info('📊 Новые параметры: статус=, окончание', status=subscription.status, end_date=subscription.end_date)
@@ -764,26 +803,39 @@ async def deactivate_subscription(db: AsyncSession, subscription: Subscription)
async def reactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
"""Реактивация подписки (например, после повторной подписки на канал).
"""Реактивация подписки (например, после повторной подписки на канал или докупки трафика).
Активирует только если подписка была DISABLED и ещё не истекла.
Активирует если подписка была DISABLED или EXPIRED и ещё не истекла по времени.
Не логирует если реактивация не требуется.
"""
now = datetime.now(UTC)
# Тихо выходим если реактивация не нужна
if subscription.status != SubscriptionStatus.DISABLED.value:
# Тихо выходим если реактивация не нужна (уже активна или другой статус)
reactivatable_statuses = {
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
}
if subscription.status not in reactivatable_statuses:
return subscription
if subscription.end_date and subscription.end_date <= now:
if not subscription.end_date or subscription.end_date <= now:
return subscription
old_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = now
await db.commit()
await db.refresh(subscription)
logger.info(
'✅ Подписка реактивирована',
subscription_id=subscription.id,
user_id=subscription.user_id,
old_status=old_status,
)
return subscription
@@ -1114,7 +1166,8 @@ async def add_subscription_servers(
await db.refresh(subscription)
if paid_prices is None:
months_remaining = get_remaining_months(subscription.end_date)
now = datetime.now(UTC)
days_remaining = max(1, (subscription.end_date - now).days)
paid_prices = []
from app.database.models import ServerSquad
@@ -1122,7 +1175,7 @@ async def add_subscription_servers(
for server_id in server_squad_ids:
result = await db.execute(select(ServerSquad.price_kopeks).where(ServerSquad.id == server_id))
server_price_per_month = result.scalar() or 0
total_price_for_period = server_price_per_month * months_remaining
total_price_for_period = int(server_price_per_month * days_remaining / 30)
paid_prices.append(total_price_for_period)
for i, server_id in enumerate(server_squad_ids):
@@ -1358,32 +1411,6 @@ async def get_subscription_server_ids(db: AsyncSession, subscription_id: int) ->
return [row[0] for row in result.fetchall()]
async def get_subscription_servers(db: AsyncSession, subscription_id: int) -> list[dict]:
from app.database.models import ServerSquad
result = await db.execute(
select(SubscriptionServer, ServerSquad)
.join(ServerSquad, SubscriptionServer.server_squad_id == ServerSquad.id)
.where(SubscriptionServer.subscription_id == subscription_id)
)
servers_info = []
for sub_server, server_squad in result.fetchall():
servers_info.append(
{
'server_id': server_squad.id,
'squad_uuid': server_squad.squad_uuid,
'display_name': server_squad.display_name,
'country_code': server_squad.country_code,
'paid_price_kopeks': sub_server.paid_price_kopeks,
'connected_at': sub_server.connected_at,
'is_available': server_squad.is_available,
}
)
return servers_info
async def remove_subscription_servers(db: AsyncSession, subscription_id: int, server_squad_ids: list[int]) -> bool:
try:
from sqlalchemy import delete
@@ -1407,229 +1434,6 @@ async def remove_subscription_servers(db: AsyncSession, subscription_id: int, se
return False
async def get_subscription_renewal_cost(
db: AsyncSession,
subscription_id: int,
period_days: int,
*,
user: User | None = None,
promo_group: PromoGroup | None = None,
) -> int:
try:
from app.config import PERIOD_PRICES
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user)
.selectinload(User.user_promo_groups)
.selectinload(UserPromoGroup.promo_group),
)
.where(Subscription.id == subscription_id)
)
subscription = result.scalar_one_or_none()
if not subscription:
return base_price
if user is None:
user = subscription.user
promo_group = promo_group or (user.promo_group if user else None)
servers_info = await get_subscription_servers(db, subscription_id)
servers_price_per_month = 0
for server_info in servers_info:
from app.database.models import ServerSquad
result = await db.execute(
select(ServerSquad.price_kopeks).where(ServerSquad.id == server_info['server_id'])
)
current_server_price = result.scalar() or 0
servers_price_per_month += current_server_price
servers_discount_percent = _get_discount_percent(
user,
promo_group,
'servers',
period_days=period_days,
)
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
total_servers_cost = discounted_servers_per_month * months_in_period
total_servers_discount = servers_discount_per_month * months_in_period
# В режиме fixed_with_topup при продлении используем фиксированный лимит
purchased_traffic = subscription.purchased_traffic_gb or 0
if settings.is_traffic_fixed():
traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit())
# Separate base traffic from purchased to avoid wrong tier lookup
elif purchased_traffic > 0:
base_traffic_gb = (subscription.traffic_limit_gb or 0) - purchased_traffic
if base_traffic_gb <= 0:
logger.warning(
'Purchased traffic >= total limit, pricing purchased portion only',
subscription_id=subscription.id,
traffic_limit_gb=subscription.traffic_limit_gb,
purchased_traffic_gb=purchased_traffic,
)
traffic_price_per_month = settings.get_traffic_price(purchased_traffic)
else:
traffic_price_per_month = settings.get_traffic_price(base_traffic_gb) + settings.get_traffic_price(
purchased_traffic
)
else:
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
traffic_discount_percent = _get_discount_percent(
user,
promo_group,
'traffic',
period_days=period_days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
total_traffic_cost = discounted_traffic_per_month * months_in_period
total_traffic_discount = traffic_discount_per_month * months_in_period
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = _get_discount_percent(
user,
promo_group,
'devices',
period_days=period_days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
total_devices_cost = discounted_devices_per_month * months_in_period
total_devices_discount = devices_discount_per_month * months_in_period
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
logger.info(
'💰 Расчет продления подписки на дней ( мес)',
subscription_id=subscription_id,
period_days=period_days,
months_in_period=months_in_period,
)
logger.info('📅 Период: ₽', base_price=base_price / 100)
if total_servers_cost > 0:
message = f' 🌍 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_cost / 100}'
if total_servers_discount > 0:
message += f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)'
logger.info(message)
if total_traffic_cost > 0:
message = (
f' 📊 Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_cost / 100}'
)
if total_traffic_discount > 0:
message += f' (скидка {traffic_discount_percent}%: -{total_traffic_discount / 100}₽)'
logger.info(message)
if total_devices_cost > 0:
message = f' 📱 Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period} = {total_devices_cost / 100}'
if total_devices_discount > 0:
message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)'
logger.info(message)
logger.info('💎 ИТОГО: ₽', total_cost=total_cost / 100)
return total_cost
except Exception as e:
logger.error('Ошибка расчета стоимости продления', error=e)
from app.config import PERIOD_PRICES
return PERIOD_PRICES.get(period_days, 0)
async def calculate_addon_cost_for_remaining_period(
db: AsyncSession,
subscription: Subscription,
additional_traffic_gb: int = 0,
additional_devices: int = 0,
additional_server_ids: list[int] = None,
*,
user: User | None = None,
promo_group: PromoGroup | None = None,
) -> int:
if additional_server_ids is None:
additional_server_ids = []
months_to_pay = get_remaining_months(subscription.end_date)
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
total_cost = 0
if user is None:
user = getattr(subscription, 'user', None)
promo_group = promo_group or (user.promo_group if user else None)
if additional_traffic_gb > 0:
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
traffic_discount_percent = _get_discount_percent(
user,
promo_group,
'traffic',
period_days=period_hint_days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
traffic_total_cost = discounted_traffic_per_month * months_to_pay
total_cost += traffic_total_cost
message = f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес × {months_to_pay} = {traffic_total_cost / 100}'
if traffic_discount_per_month > 0:
message += f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay / 100}₽)'
logger.info(message)
if additional_devices > 0:
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = _get_discount_percent(
user,
promo_group,
'devices',
period_days=period_hint_days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
devices_total_cost = discounted_devices_per_month * months_to_pay
total_cost += devices_total_cost
message = f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес × {months_to_pay} = {devices_total_cost / 100}'
if devices_discount_per_month > 0:
message += f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay / 100}₽)'
logger.info(message)
if additional_server_ids:
from app.database.models import ServerSquad
for server_id in additional_server_ids:
result = await db.execute(
select(ServerSquad.price_kopeks, ServerSquad.display_name).where(ServerSquad.id == server_id)
)
server_data = result.first()
if server_data:
server_price_per_month, server_name = server_data
servers_discount_percent = _get_discount_percent(
user,
promo_group,
'servers',
period_days=period_hint_days,
)
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
discounted_server_per_month = server_price_per_month - server_discount_per_month
server_total_cost = discounted_server_per_month * months_to_pay
total_cost += server_total_cost
message = f'Сервер {server_name}: {server_price_per_month / 100}₽/мес × {months_to_pay} = {server_total_cost / 100}'
if server_discount_per_month > 0:
message += (
f' (скидка {servers_discount_percent}%: -{server_discount_per_month * months_to_pay / 100}₽)'
)
logger.info(message)
logger.info('💰 Итого доплата за мес: ₽', months_to_pay=months_to_pay, total_cost=total_cost / 100)
return total_cost
async def expire_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.updated_at = datetime.now(UTC)
@@ -2094,6 +1898,9 @@ async def get_disabled_daily_subscriptions_for_resume(
Subscription.status == SubscriptionStatus.DISABLED.value,
User.status == UserStatus.ACTIVE.value,
Subscription.is_trial.is_(False),
# Не возобновляем подписки, приостановленные пользователем вручную
# is_(False) не ловит NULL, поэтому добавляем OR is_(None)
(Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)),
# Баланс пользователя >= суточной цены тарифа
User.balance_kopeks >= Tariff.daily_price_kopeks,
)
@@ -2135,7 +1942,8 @@ async def get_expired_daily_subscriptions_for_recovery(db: AsyncSession) -> list
Tariff.is_active.is_(True),
Subscription.status == SubscriptionStatus.EXPIRED.value,
User.status == UserStatus.ACTIVE.value,
Subscription.is_daily_paused.is_(False),
# is_(False) не ловит NULL, поэтому добавляем OR is_(None)
(Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)),
Subscription.is_trial.is_(False),
# Только недавно экспайренные
Subscription.updated_at >= recovery_threshold,
@@ -2190,8 +1998,12 @@ async def resume_daily_subscription(
subscription.is_daily_paused = False
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED
if subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED/LIMITED
if subscription.status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
):
previous_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
# Обновляем время последнего списания для корректного расчёта следующего
+7
View File
@@ -25,6 +25,13 @@ async def upsert_system_setting(
return setting
async def get_setting_value(db: AsyncSession, key: str) -> str | None:
"""Get a setting value from database."""
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def delete_system_setting(db: AsyncSession, key: str) -> None:
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
+18
View File
@@ -185,8 +185,12 @@ async def create_tariff(
traffic_price_per_gb_kopeks: int = 0,
min_traffic_gb: int = 1,
max_traffic_gb: int = 1000,
# Видимость в разделе подарков
show_in_gift: bool = True,
# Режим сброса трафика
traffic_reset_mode: str | None = None, # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = None,
) -> Tariff:
"""Создает новый тариф."""
normalized_prices = _normalize_period_prices(period_prices)
@@ -221,8 +225,12 @@ async def create_tariff(
traffic_price_per_gb_kopeks=max(0, traffic_price_per_gb_kopeks),
min_traffic_gb=max(1, min_traffic_gb),
max_traffic_gb=max(1, max_traffic_gb),
# Видимость в разделе подарков
show_in_gift=show_in_gift,
# Режим сброса трафика
traffic_reset_mode=traffic_reset_mode,
# Внешний сквад
external_squad_uuid=external_squad_uuid,
)
db.add(tariff)
@@ -286,8 +294,12 @@ async def update_tariff(
traffic_price_per_gb_kopeks: int | None = None,
min_traffic_gb: int | None = None,
max_traffic_gb: int | None = None,
# Видимость в разделе подарков
show_in_gift: bool | None = None,
# Режим сброса трафика
traffic_reset_mode: str | None = ..., # ... = не передан, None = сбросить к глобальной настройке
# Внешний сквад RemnaWave
external_squad_uuid: str | None = ..., # ... = не передан, None = убрать внешний сквад
) -> Tariff:
"""Обновляет существующий тариф."""
if name is not None:
@@ -348,9 +360,15 @@ async def update_tariff(
tariff.min_traffic_gb = max(1, min_traffic_gb)
if max_traffic_gb is not None:
tariff.max_traffic_gb = max(1, max_traffic_gb)
# Видимость в разделе подарков
if show_in_gift is not None:
tariff.show_in_gift = show_in_gift
# Режим сброса трафика
if traffic_reset_mode is not ...:
tariff.traffic_reset_mode = traffic_reset_mode
# Внешний сквад
if external_squad_uuid is not ...:
tariff.external_squad_uuid = external_squad_uuid
# Обновляем промогруппы если указаны
if promo_group_ids is not None:
+4 -2
View File
@@ -41,10 +41,12 @@ async def create_transaction(
*,
commit: bool = True,
) -> Transaction:
# SUBSCRIPTION_PAYMENT — always store as negative (debit from user balance)
# SUBSCRIPTION_PAYMENT / GIFT_PAYMENT — always store as negative (debit from user balance)
# Keep original for downstream consumers (events, contests)
stored_amount = (
-amount_kopeks if type == TransactionType.SUBSCRIPTION_PAYMENT and amount_kopeks > 0 else amount_kopeks
-amount_kopeks
if type in (TransactionType.SUBSCRIPTION_PAYMENT, TransactionType.GIFT_PAYMENT) and amount_kopeks > 0
else amount_kopeks
)
transaction = Transaction(
+79 -54
View File
@@ -122,6 +122,30 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> User |
return user
async def find_phantom_user_by_username(db: AsyncSession, username: str) -> User | None:
"""Find a phantom user created by guest purchase (no telegram_id, auth_type=telegram).
Used during /start to reconcile phantom users with real Telegram accounts.
"""
if not username:
return None
normalized = username.lower()
result = await db.execute(
select(User)
.options(
selectinload(User.subscription).selectinload(Subscription.tariff),
)
.where(
User.telegram_id.is_(None),
User.auth_type == 'telegram',
func.lower(User.username) == normalized,
)
.with_for_update()
)
return result.scalars().first()
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
if not username:
return None
@@ -386,6 +410,18 @@ async def update_user(db: AsyncSession, user: User, **kwargs) -> User:
return user
async def lock_user_for_update(db: AsyncSession, user: User) -> User:
"""Lock user row with SELECT FOR UPDATE to prevent concurrent balance modifications.
Returns the refreshed user object with current DB values.
Must be called within an active transaction before modifying balance_kopeks.
"""
result = await db.execute(
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
)
return result.scalar_one()
async def add_user_balance(
db: AsyncSession,
user: User,
@@ -397,6 +433,12 @@ async def add_user_balance(
payment_method: PaymentMethod | None = None,
) -> bool:
try:
# Lock the user row to prevent concurrent balance race conditions
locked_result = await db.execute(
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
)
user = locked_result.scalar_one()
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.now(UTC)
@@ -425,40 +467,10 @@ async def add_user_balance(
amount_kopeks=amount_kopeks,
)
# Автоматическое возобновление приостановленной суточной подписки
try:
from app.database.crud.subscription import get_subscription_by_user_id, resume_daily_subscription
from app.database.crud.tariff import get_tariff_by_id
from app.database.models import SubscriptionStatus
# Загружаем подписку явно, чтобы избежать lazy loading
subscription = await get_subscription_by_user_id(db, user.id)
if subscription and subscription.status == SubscriptionStatus.DISABLED.value:
# Проверяем что это суточный тариф
is_daily = getattr(subscription, 'is_daily_tariff', False)
if is_daily and subscription.tariff_id:
# Загружаем тариф явно
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# Если баланс достаточный для суточной оплаты - возобновляем
if daily_price > 0 and user.balance_kopeks >= daily_price:
await resume_daily_subscription(db, subscription)
logger.info(
'✅ Автоматически возобновлена суточная подписка после пополнения баланса (user_id=)',
subscription_id=subscription.id,
user_id=user.id,
)
# Синхронизируем с RemnaWave
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as sync_err:
logger.warning('Не удалось синхронизировать с RemnaWave', sync_err=sync_err)
except Exception as resume_err:
logger.warning('Ошибка при попытке возобновить суточную подписку', resume_err=resume_err)
# Авто-возобновление суточной подписки НЕ делаем здесь —
# это обязанность try_resume_disabled_daily_after_topup (через send_cart_notification_after_topup)
# и DailySubscriptionService.process_auto_resume (30-минутный цикл).
# Они корректно списывают суточную плату при возобновлении.
return True
@@ -504,15 +516,22 @@ async def subtract_user_balance(
create_transaction: bool = False,
payment_method: PaymentMethod | None = None,
*,
transaction_type: TransactionType = TransactionType.WITHDRAWAL,
consume_promo_offer: bool = False,
mark_as_paid_subscription: bool = False,
commit: bool = True,
) -> bool:
user_id_display = user.telegram_id or user.email or f'#{user.id}'
logger.info('💸 ОТЛАДКА subtract_user_balance:')
logger.info('👤 User ID: (ID: )', user_id=user.id, user_id_display=user_id_display)
logger.info('💰 Баланс до списания: копеек', balance_kopeks=user.balance_kopeks)
logger.info('💸 Сумма к списанию: копеек', amount_kopeks=amount_kopeks)
logger.info('📝 Описание', description=description)
if amount_kopeks < 0:
logger.error('subtract_user_balance called with negative amount', amount_kopeks=amount_kopeks, user_id=user.id)
return False
logger.debug(
'subtract_user_balance called',
user_id=user.id,
balance_kopeks=user.balance_kopeks,
amount_kopeks=amount_kopeks,
description=description,
)
# Lock the user row to prevent concurrent balance race conditions
locked_result = await db.execute(
@@ -577,20 +596,22 @@ async def subtract_user_balance(
create_transaction as create_trans,
)
# create_trans commits the session, atomically persisting
# both the balance change and the transaction record
await create_trans(
db=db,
user_id=user.id,
type=TransactionType.WITHDRAWAL,
type=transaction_type,
amount_kopeks=amount_kopeks,
description=description,
payment_method=payment_method,
commit=commit,
)
else:
elif commit:
await db.commit()
else:
await db.flush()
await db.refresh(user)
if commit:
await db.refresh(user)
if consume_promo_offer and log_context:
try:
@@ -603,26 +624,30 @@ async def subtract_user_balance(
percent=log_context.get('percent'),
effect_type=log_context.get('effect_type'),
details=log_context.get('details'),
commit=commit,
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to record promo offer consumption log for user', user_id=user.id, log_error=log_error
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to rollback session after promo offer consumption log failure',
rollback_error=rollback_error,
)
if commit:
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to rollback session after promo offer consumption log failure',
rollback_error=rollback_error,
)
logger.info('✅ Средства списаны: →', old_balance=old_balance, balance_kopeks=user.balance_kopeks)
return True
except Exception as e:
logger.error('❌ ОШИБКА СПИСАНИЯ', error=e)
await db.rollback()
return False
if commit:
await db.rollback()
return False
raise
async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
+2 -1
View File
@@ -40,6 +40,7 @@ async def _sync_user_primary_promo_group(
except Exception as error:
logger.error('Ошибка синхронизации primary промогруппы пользователя', user_id=user_id, error=error)
raise
async def sync_user_primary_promo_group(
@@ -187,7 +188,7 @@ async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoG
return None
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None
return user_promo_groups[0].promo_group or None
except Exception as error:
logger.error('Ошибка получения primary промогруппы пользователя', user_id=user_id, error=error)
+1 -1
View File
@@ -16,7 +16,7 @@ logger = structlog.get_logger(__name__)
async def create_wata_payment(
db: AsyncSession,
*,
user_id: int,
user_id: int | None,
payment_link_id: str,
amount_kopeks: int,
currency: str,
+1 -1
View File
@@ -14,7 +14,7 @@ logger = structlog.get_logger(__name__)
async def create_yookassa_payment(
db: AsyncSession,
user_id: int,
user_id: int | None,
yookassa_payment_id: str,
amount_kopeks: int,
currency: str,
+38 -6
View File
@@ -23,26 +23,58 @@ def _get_alembic_config() -> Config:
return cfg
async def _needs_auto_stamp() -> bool:
"""Check if DB has existing tables but no alembic_version (transition from universal_migration)."""
async def _detect_db_state() -> str:
"""Detect database state: 'fresh', 'legacy', or 'managed'.
- fresh: no tables at all brand new database
- legacy: has tables but no alembic_version (transition from universal_migration)
- managed: has alembic_version already managed by Alembic
"""
from app.database.database import engine
async with engine.connect() as conn:
has_alembic = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table('alembic_version'))
if has_alembic:
return False
return 'managed'
has_users = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table('users'))
return has_users
return 'legacy' if has_users else 'fresh'
_INITIAL_REVISION = '0001'
async def _bootstrap_fresh_db() -> None:
"""Bootstrap a fresh database: create all tables from models and stamp at head.
On a fresh DB, running all migrations sequentially would fail because
migration 0001 uses Base.metadata.create_all() which creates ALL tables
from the current models.py (including columns/constraints/indexes added
by later migrations), and then those later migrations try to re-create
the same objects. Instead, we create the full schema directly and stamp
the migration history at HEAD so Alembic considers all migrations applied.
"""
from app.database.database import engine
from app.database.models import Base
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info('Свежая БД: все таблицы созданы из моделей')
async def run_alembic_upgrade() -> None:
"""Run ``alembic upgrade head``, auto-stamping existing databases first."""
"""Run ``alembic upgrade head``, handling fresh and legacy databases."""
import asyncio
if await _needs_auto_stamp():
db_state = await _detect_db_state()
if db_state == 'fresh':
logger.warning('Обнаружена пустая БД — создание схемы из моделей + stamp head')
await _bootstrap_fresh_db()
await _stamp_alembic_revision('head')
return
if db_state == 'legacy':
logger.warning(
'Обнаружена существующая БД без alembic_version — автоматический stamp 0001 (переход с universal_migration)'
)
+257 -17
View File
@@ -8,12 +8,13 @@ def _aware(dt: datetime | None) -> datetime | None:
return dt
from enum import Enum
from enum import Enum, StrEnum
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
CheckConstraint,
Column,
Date,
DateTime,
@@ -122,6 +123,7 @@ class SubscriptionStatus(Enum):
ACTIVE = 'active'
EXPIRED = 'expired'
DISABLED = 'disabled'
LIMITED = 'limited'
PENDING = 'pending'
@@ -132,6 +134,7 @@ class TransactionType(Enum):
REFUND = 'refund'
REFERRAL_REWARD = 'referral_reward'
POLL_REWARD = 'poll_reward'
GIFT_PAYMENT = 'gift_payment'
class PromoCodeType(Enum):
@@ -155,6 +158,7 @@ class PaymentMethod(Enum):
CLOUDPAYMENTS = 'cloudpayments'
FREEKASSA = 'freekassa'
KASSA_AI = 'kassa_ai'
RIOPAY = 'riopay'
MANUAL = 'manual'
BALANCE = 'balance'
@@ -191,7 +195,7 @@ class YooKassaPayment(Base):
__tablename__ = 'yookassa_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
yookassa_payment_id = Column(String(255), unique=True, nullable=False, index=True)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(3), default='RUB', nullable=False)
@@ -236,11 +240,43 @@ class YooKassaPayment(Base):
return f'<YooKassaPayment(id={self.id}, yookassa_id={self.yookassa_payment_id}, amount={self.amount_rubles}₽, status={self.status})>'
class SavedPaymentMethod(Base):
__tablename__ = 'saved_payment_methods'
__table_args__ = (Index('ix_saved_payment_methods_user_active', 'user_id', 'is_active'),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
# YooKassa payment_method.id — ключ для рекуррентных списаний
yookassa_payment_method_id = Column(String(255), unique=True, nullable=False, index=True)
# Тип метода: bank_card, yoo_money, sberbank, tinkoff_bank, sbp, mir_pay
method_type = Column(String(50), nullable=False, default='bank_card')
# Отображаемые данные карты (маскированные)
card_first6 = Column(String(6), nullable=True)
card_last4 = Column(String(4), nullable=True)
card_type = Column(String(50), nullable=True) # Visa, MasterCard, Mir
card_expiry_month = Column(String(2), nullable=True)
card_expiry_year = Column(String(4), nullable=True)
title = Column(String(255), nullable=True) # "Bank card *4444"
is_active = Column(Boolean, default=True)
created_at = Column(AwareDateTime(), nullable=False, server_default=func.now())
updated_at = Column(AwareDateTime(), nullable=False, server_default=func.now(), onupdate=func.now())
user = relationship('User', backref='saved_payment_methods')
def __repr__(self):
return f'<SavedPaymentMethod(id={self.id}, user_id={self.user_id}, type={self.method_type}, last4={self.card_last4})>'
class CryptoBotPayment(Base):
__tablename__ = 'cryptobot_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
invoice_id = Column(String(255), unique=True, nullable=False, index=True)
amount = Column(String(50), nullable=False)
@@ -290,7 +326,7 @@ class HeleketPayment(Base):
__tablename__ = 'heleket_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
uuid = Column(String(255), unique=True, nullable=False, index=True)
order_id = Column(String(128), unique=True, nullable=False, index=True)
@@ -349,7 +385,7 @@ class MulenPayPayment(Base):
__tablename__ = 'mulenpay_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
mulen_payment_id = Column(Integer, nullable=True, index=True)
uuid = Column(String(255), unique=True, nullable=False, index=True)
@@ -385,7 +421,7 @@ class Pal24Payment(Base):
__tablename__ = 'pal24_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
bill_id = Column(String(255), unique=True, nullable=False, index=True)
order_id = Column(String(255), nullable=True, index=True)
@@ -442,7 +478,7 @@ class WataPayment(Base):
__tablename__ = 'wata_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
payment_link_id = Column(String(64), unique=True, nullable=False, index=True)
order_id = Column(String(255), nullable=True, index=True)
@@ -485,7 +521,7 @@ class PlategaPayment(Base):
__tablename__ = 'platega_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
platega_transaction_id = Column(String(255), unique=True, nullable=True, index=True)
correlation_id = Column(String(64), unique=True, nullable=False, index=True)
@@ -527,7 +563,7 @@ class CloudPaymentsPayment(Base):
__tablename__ = 'cloudpayments_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
# CloudPayments идентификаторы
transaction_id_cp = Column(BigInteger, unique=True, nullable=True, index=True) # TransactionId от CloudPayments
@@ -596,7 +632,7 @@ class FreekassaPayment(Base):
__tablename__ = 'freekassa_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш ID заказа
@@ -658,7 +694,7 @@ class KassaAiPayment(Base):
__tablename__ = 'kassa_ai_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш ID заказа
@@ -714,6 +750,68 @@ class KassaAiPayment(Base):
return f'<KassaAiPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class RioPayPayment(Base):
"""Платежи через RioPay (api.riopay.online)."""
__tablename__ = 'riopay_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
riopay_order_id = Column(String(64), unique=True, nullable=True, index=True) # UUID от RioPay
# Суммы
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') # pending, success, failed, expired, canceled
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_method = Column(String(32), nullable=True) # CARD, SBP
# Метаданные
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='riopay_payments')
transaction = relationship('Transaction', backref='riopay_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']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<RioPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class PromoGroup(Base):
__tablename__ = 'promo_groups'
@@ -877,9 +975,15 @@ class Tariff(Base):
min_traffic_gb = Column(Integer, default=1, nullable=False) # Минимальный трафик в ГБ
max_traffic_gb = Column(Integer, default=1000, nullable=False) # Максимальный трафик в ГБ
# Видимость в разделе подарков
show_in_gift = Column(Boolean, default=True, server_default='true', nullable=False)
# Режим сброса трафика: DAY, WEEK, MONTH, NO_RESET (по умолчанию берётся из конфига)
traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку
# Внешний сквад RemnaWave (UUID) — назначается пользователю при создании подписки
external_squad_uuid = Column(String(255), nullable=True, default=None)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
@@ -1044,7 +1148,9 @@ class User(Base):
discord_id = Column(String(255), unique=True, nullable=True, index=True)
vk_id = Column(BigInteger, unique=True, nullable=True, index=True)
broadcasts = relationship('BroadcastHistory', back_populates='admin')
referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id')
referrals = relationship(
'User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id', post_update=True
)
subscription = relationship('Subscription', back_populates='user', uselist=False)
transactions = relationship('Transaction', back_populates='user')
referral_earnings = relationship('ReferralEarning', foreign_keys='ReferralEarning.user_id', back_populates='user')
@@ -1119,10 +1225,10 @@ class User(Base):
def get_primary_promo_group(self):
"""Возвращает промогруппу с максимальным приоритетом."""
if not self.user_promo_groups:
return getattr(self, 'promo_group', None)
try:
if not self.user_promo_groups:
return getattr(self, 'promo_group', None)
# Сортируем по приоритету группы (убывание), затем по ID группы
# Используем getattr для защиты от ленивой загрузки
sorted_groups = sorted(
@@ -1134,7 +1240,7 @@ class User(Base):
if sorted_groups and sorted_groups[0].promo_group:
return sorted_groups[0].promo_group
except Exception:
# Если возникла ошибка (например, ленивая загрузка), fallback на старую связь
# Если возникла ошибка (например, ленивая загрузка в async), fallback на старую связь
pass
# Fallback на старую связь если новая пустая или возникла ошибка
@@ -1245,6 +1351,9 @@ class Subscription(Base):
if self.status == SubscriptionStatus.DISABLED.value:
return 'disabled'
if self.status == SubscriptionStatus.LIMITED.value:
return 'limited'
if self.status == SubscriptionStatus.ACTIVE.value:
if end is None or end <= current_time:
return 'expired'
@@ -1269,6 +1378,8 @@ class Subscription(Base):
return '🟢 Активна'
if actual_status == 'disabled':
return '⚫ Отключена'
if actual_status == 'limited':
return '⚠️ Трафик исчерпан'
if actual_status == 'trial':
return '🎯 Тестовая'
@@ -1286,6 +1397,8 @@ class Subscription(Base):
return '💎'
if actual_status == 'disabled':
return ''
if actual_status == 'limited':
return '⚠️'
if actual_status == 'trial':
return '🎁'
@@ -1334,7 +1447,7 @@ class Subscription(Base):
else:
self.end_date = datetime.now(UTC) + timedelta(days=days)
if self.status == SubscriptionStatus.EXPIRED.value:
if self.status in (SubscriptionStatus.EXPIRED.value, SubscriptionStatus.LIMITED.value):
self.status = SubscriptionStatus.ACTIVE.value
def add_traffic(self, gb: int):
@@ -1857,6 +1970,25 @@ class SystemSetting(Base):
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
class EmailTemplate(Base):
"""Custom email template overrides (accessed via raw SQL in cabinet services)."""
__tablename__ = 'email_templates'
__table_args__ = (
UniqueConstraint('notification_type', 'language', name='uq_email_templates_type_lang'),
Index('ix_email_templates_notification_type', 'notification_type'),
)
id = Column(Integer, primary_key=True)
notification_type = Column(String(100), nullable=False)
language = Column(String(10), nullable=False)
subject = Column(String(500), nullable=False)
body_html = Column(Text, nullable=False)
is_active = Column(Boolean, nullable=False, server_default='true')
created_at = Column(AwareDateTime(), nullable=False, server_default=func.now())
updated_at = Column(AwareDateTime(), nullable=False, server_default=func.now())
class MonitoringLog(Base):
__tablename__ = 'monitoring_logs'
@@ -2988,3 +3120,111 @@ class AdminAuditLog(Base):
def __repr__(self) -> str:
return f'<AdminAuditLog id={self.id} action={self.action!r} status={self.status!r}>'
class LandingPage(Base):
"""Public quick-purchase landing page configuration."""
__tablename__ = 'landing_pages'
__table_args__ = (
CheckConstraint(
'discount_percent IS NULL OR (discount_percent >= 1 AND discount_percent <= 99)',
name='chk_landing_discount_percent_range',
),
CheckConstraint(
'discount_starts_at IS NULL OR discount_ends_at IS NULL OR discount_starts_at < discount_ends_at',
name='chk_landing_discount_dates_order',
),
)
id = Column(Integer, primary_key=True, index=True)
slug = Column(String(100), unique=True, nullable=False, index=True)
is_active = Column(Boolean, nullable=False, default=True)
title = Column(JSON, nullable=False, default=dict)
subtitle = Column(JSON, nullable=True)
features = Column(JSON, nullable=False, default=list)
footer_text = Column(JSON, nullable=True)
allowed_tariff_ids = Column(JSON, nullable=False, default=list)
allowed_periods = Column(JSON, nullable=False, default=dict)
payment_methods = Column(JSON, nullable=False, default=list)
gift_enabled = Column(Boolean, nullable=False, default=True)
custom_css = Column(Text, nullable=True)
meta_title = Column(JSON, nullable=True)
meta_description = Column(JSON, nullable=True)
display_order = Column(Integer, nullable=False, default=0)
discount_percent = Column(Integer, nullable=True) # 1-99, global discount for all tariffs
discount_overrides = Column(JSON, nullable=True) # {"tariff_id": percent} per-tariff override
discount_starts_at = Column(AwareDateTime(), nullable=True)
discount_ends_at = Column(AwareDateTime(), nullable=True)
discount_badge_text = Column(JSON, nullable=True) # LocaleDict {"ru": "...", "en": "..."}
background_config = Column(
JSON, nullable=True
) # AnimationConfig: {enabled, type, settings, opacity, blur, reducedOnMobile}
created_at = Column(AwareDateTime(), server_default=func.now())
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
guest_purchases = relationship('GuestPurchase', back_populates='landing', lazy='noload')
def __repr__(self) -> str:
return f"<LandingPage slug='{self.slug}' active={self.is_active}>"
class GuestPurchaseStatus(StrEnum):
PENDING = 'pending'
PAID = 'paid'
DELIVERED = 'delivered'
PENDING_ACTIVATION = 'pending_activation'
FAILED = 'failed'
EXPIRED = 'expired'
class GuestPurchase(Base):
"""Guest (unauthenticated) purchase record."""
__tablename__ = 'guest_purchases'
__table_args__ = (
Index('ix_guest_purchases_status', 'status'),
Index('ix_guest_purchases_contact', 'contact_type', 'contact_value'),
Index('ix_guest_purchases_landing_status_paid', 'landing_id', 'status', 'paid_at'),
Index('ix_guest_purchases_source', 'source'),
Index('ix_guest_purchases_user_gift_status', 'user_id', 'is_gift', 'status'),
Index('ix_guest_purchases_status_paid_at', 'status', 'paid_at'),
Index('ix_guest_purchases_buyer_user_id', 'buyer_user_id'),
)
id = Column(Integer, primary_key=True, index=True)
token = Column(String(64), unique=True, nullable=False, index=True)
landing_id = Column(Integer, ForeignKey('landing_pages.id', ondelete='SET NULL'), nullable=True)
contact_type = Column(String(20), nullable=False) # 'email' or 'telegram'
contact_value = Column(String(255), nullable=False)
is_gift = Column(Boolean, nullable=False, default=False)
source = Column(String(20), nullable=False, default='landing', server_default='landing') # 'landing' or 'cabinet'
buyer_user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
gift_recipient_type = Column(String(20), nullable=True)
gift_recipient_value = Column(String(255), nullable=True)
gift_message = Column(Text, nullable=True)
tariff_id = Column(Integer, ForeignKey('tariffs.id', ondelete='SET NULL'), nullable=True)
period_days = Column(Integer, nullable=False)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(3), nullable=False, default='RUB')
payment_method = Column(String(50), nullable=True)
payment_id = Column(String(255), nullable=True)
status = Column(String(20), nullable=False, default=GuestPurchaseStatus.PENDING.value)
subscription_url = Column(Text, nullable=True)
subscription_crypto_link = Column(Text, nullable=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
created_at = Column(AwareDateTime(), server_default=func.now())
paid_at = Column(AwareDateTime(), nullable=True)
delivered_at = Column(AwareDateTime(), nullable=True)
cabinet_password = Column(Text, nullable=True)
auto_login_token = Column(Text, nullable=True)
recipient_warning = Column(String(50), nullable=True)
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
tariff = relationship('Tariff', lazy='selectin')
user = relationship('User', foreign_keys=[user_id], lazy='selectin')
buyer = relationship('User', foreign_keys=[buyer_user_id], lazy='selectin')
def __repr__(self) -> str:
token_prefix = self.token[:5] if self.token else '?'
return f"<GuestPurchase token='{token_prefix}...' status='{self.status}'>"
+12 -1
View File
@@ -400,7 +400,12 @@ class RemnaWaveAPI:
if response.status >= 400:
error_message = response_data.get('message', f'HTTP {response.status}')
log = logger.warning if response.status in (502, 503, 504) else logger.error
# Downgrade known-harmless 400s to warning (caller handles them as success)
error_lower = str(error_message).lower()
is_harmless = response.status == 400 and (
'already enabled' in error_lower or 'already disabled' in error_lower
)
log = logger.warning if response.status in (502, 503, 504) or is_harmless else logger.error
log('API Error %s: %s', response.status, error_message)
log('Response: %s', response_text[:500])
raise RemnaWaveAPIError(error_message, response.status, response_data)
@@ -439,6 +444,7 @@ class RemnaWaveAPI:
description: str | None = None,
tag: str | None = None,
active_internal_squads: list[str] | None = None,
external_squad_uuid: str | None = None,
) -> RemnaWaveUser:
data = {
'username': username,
@@ -460,6 +466,8 @@ class RemnaWaveAPI:
data['tag'] = tag
if active_internal_squads:
data['activeInternalSquads'] = active_internal_squads
if external_squad_uuid is not None:
data['externalSquadUuid'] = external_squad_uuid
logger.info(
'POST /api/users payload',
@@ -539,6 +547,7 @@ class RemnaWaveAPI:
description: str | None = None,
tag: str | None = None,
active_internal_squads: list[str] | None = None,
external_squad_uuid: str | None | type(...) = ...,
) -> RemnaWaveUser:
data = {'uuid': uuid}
@@ -562,6 +571,8 @@ class RemnaWaveAPI:
data['tag'] = tag
if active_internal_squads is not None:
data['activeInternalSquads'] = active_internal_squads
if external_squad_uuid is not ...:
data['externalSquadUuid'] = external_squad_uuid
logger.info(
'PATCH /api/users payload',
+1 -1
View File
@@ -29,7 +29,7 @@ async def show_blacklist_settings(callback: types.CallbackQuery, db_user: User,
blacklist_count = len(await blacklist_service.get_all_blacklisted_users())
status_text = '✅ Включена' if is_enabled else '❌ Отключена'
url_text = github_url if github_url else 'Не задан'
url_text = github_url or 'Не задан'
text = f"""
🔐 <b>Настройки черного списка</b>
+48 -1
View File
@@ -63,7 +63,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
},
'payments': {
'title': '💳 Платежные системы',
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI и Telegram Stars.',
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay и Telegram Stars.',
'icon': '💳',
'categories': (
'PAYMENT',
@@ -74,6 +74,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
'CLOUDPAYMENTS',
'FREEKASSA',
'KASSA_AI',
'RIOPAY',
'MULENPAY',
'PAL24',
'WATA',
@@ -263,6 +264,7 @@ def _get_group_status(group_key: str) -> tuple[str, str]:
'CloudPayments': settings.is_cloudpayments_enabled(),
'Freekassa': settings.is_freekassa_enabled(),
'Kassa AI': settings.is_kassa_ai_enabled(),
'RioPay': settings.is_riopay_enabled(),
'MulenPay': settings.is_mulenpay_enabled(),
'PAL24': settings.is_pal24_enabled(),
'Tribute': settings.TRIBUTE_ENABLED,
@@ -1251,6 +1253,9 @@ def _build_settings_keyboard(
elif category_key == 'KASSA_AI':
label = texts.t('PAYMENT_KASSA_AI', f'💳 {settings.get_kassa_ai_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'kassa_ai')])
elif category_key == 'RIOPAY':
label = texts.t('PAYMENT_RIOPAY', f'💳 {settings.get_riopay_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'riopay')])
if test_payment_buttons:
rows.extend(test_payment_buttons)
@@ -2325,6 +2330,48 @@ async def test_payment_provider(
await _refresh_markup()
return
if method == 'riopay':
if not settings.is_riopay_enabled():
await callback.answer('❌ RioPay отключена', show_alert=True)
return
amount_kopeks = settings.RIOPAY_MIN_AMOUNT_KOPEKS
payment_result = await payment_service.create_riopay_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description='Тестовый платеж RioPay (админ)',
email=getattr(db_user, 'email', None),
language=db_user.language or settings.DEFAULT_LANGUAGE,
)
if not payment_result or not payment_result.get('payment_url'):
await callback.answer('❌ Не удалось создать тестовый платеж RioPay', show_alert=True)
await _refresh_markup()
return
payment_url = payment_result['payment_url']
display_name = settings.get_riopay_display_name()
message_text = (
f'🧪 <b>Тестовый платеж {display_name}</b>\n\n'
f'💰 Сумма: {texts.format_price(amount_kopeks)}\n'
f'🆔 Order ID: {payment_result["order_id"]}'
)
reply_markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text='💳 Перейти к оплате',
url=payment_url,
)
]
]
)
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML')
await callback.answer(f'✅ Ссылка на платеж {display_name} отправлена', show_alert=True)
await _refresh_markup()
return
await callback.answer('❌ Неизвестный способ тестирования платежа', show_alert=True)
await _refresh_markup()
+10 -2
View File
@@ -1557,7 +1557,11 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
if target == 'expired':
# Истекшие подписки
now = datetime.now(UTC)
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
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)
@@ -1576,7 +1580,11 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
if target == 'expired_subscribers':
# То же что и expired
now = datetime.now(UTC)
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
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)
+4 -4
View File
@@ -1350,12 +1350,12 @@ async def _do_reconcile_logs(callback: CallbackQuery):
await callback.answer('🔄 Анализирую логи платежей...', show_alert=False)
# Путь к файлу логов платежей (logs/current/)
log_file_path = Path(settings.LOG_FILE).resolve()
log_file_path = await asyncio.to_thread(Path(settings.LOG_FILE).resolve)
log_dir = log_file_path.parent
current_dir = log_dir / 'current'
payments_log = current_dir / settings.LOG_PAYMENTS_FILE
if not payments_log.exists():
if not await asyncio.to_thread(payments_log.exists):
try:
await callback.message.edit_text(
'❌ <b>Файл логов не найден</b>\n\n'
@@ -1491,12 +1491,12 @@ async def receipts_reconcile_logs_details_callback(callback: CallbackQuery):
await callback.answer('🔄 Загружаю детали...', show_alert=False)
# Путь к логам (logs/current/)
log_file_path = Path(settings.LOG_FILE).resolve()
log_file_path = await asyncio.to_thread(Path(settings.LOG_FILE).resolve)
log_dir = log_file_path.parent
current_dir = log_dir / 'current'
payments_log = current_dir / settings.LOG_PAYMENTS_FILE
if not payments_log.exists():
if not await asyncio.to_thread(payments_log.exists):
await callback.answer('❌ Файл логов не найден', show_alert=True)
return
+4 -2
View File
@@ -48,6 +48,8 @@ def _method_display(method: PaymentMethod) -> str:
return 'Telegram Stars'
if method == PaymentMethod.KASSA_AI:
return settings.get_kassa_ai_display_name()
if method == PaymentMethod.RIOPAY:
return settings.get_riopay_display_name()
if method == PaymentMethod.FREEKASSA:
return settings.get_freekassa_display_name()
return method.value
@@ -186,7 +188,7 @@ def _is_checkable(record: PendingPayment) -> bool:
if record.method == PaymentMethod.YOOKASSA:
return status in {'pending', 'waiting_for_capture'}
if record.method == PaymentMethod.CRYPTOBOT:
return status in {'active'}
return status == 'active'
if record.method == PaymentMethod.FREEKASSA:
return status in {'pending', 'created', ''}
if record.method == PaymentMethod.KASSA_AI:
@@ -378,7 +380,7 @@ def _build_payment_details_text(record: PendingPayment, *, texts, language: str)
amount = f'{crypto_amount} {crypto_asset}'
created = format_datetime(record.created_at)
age = format_time_ago(record.created_at, language)
raw_identifier = record.identifier if record.identifier else record.local_id
raw_identifier = record.identifier or record.local_id
identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else ''
lines = [
texts.t('ADMIN_PAYMENT_DETAILS_TITLE', '💳 <b>Payment details</b>'),
+1 -1
View File
@@ -977,7 +977,7 @@ async def _render_squad_selection(
if not selected_server:
selected_server = await get_server_squad_by_uuid(db, selected_uuid)
if selected_server:
selected_server_name = selected_server.display_name
selected_server_name = html.escape(selected_server.display_name)
header = texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE', '🌍 <b>Выберите сквад</b>')
if selected_server_name:
+8 -4
View File
@@ -1,3 +1,4 @@
import asyncio
import json
from datetime import UTC, datetime, timedelta
@@ -628,6 +629,9 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d
db.add(earning)
# Добавляем на баланс пользователя
from app.database.crud.user import lock_user_for_update
target_user = await lock_user_for_update(db, target_user)
target_user.balance_kopeks += amount_kopeks
await db.commit()
@@ -756,8 +760,8 @@ async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncS
# Информация о логах
log_path = referral_diagnostics_service.log_path
log_exists = log_path.exists()
log_size = log_path.stat().st_size if log_exists else 0
log_exists = await asyncio.to_thread(log_path.exists)
log_size = (await asyncio.to_thread(log_path.stat)).st_size if log_exists else 0
text += f'\n<i>📂 {log_path.name}'
if log_exists:
@@ -1434,9 +1438,9 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
finally:
# Удаляем временный файл
if temp_file_path and Path(temp_file_path).exists():
if temp_file_path and await asyncio.to_thread(Path(temp_file_path).exists):
try:
Path(temp_file_path).unlink()
await asyncio.to_thread(Path(temp_file_path).unlink)
logger.info('🗑️ Временный файл удалён', temp_file_path=temp_file_path)
except Exception as e:
logger.error('Ошибка удаления временного файла', error=e)
+2 -1
View File
@@ -1,3 +1,4 @@
import html
import math
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -175,7 +176,7 @@ def _format_migration_server_label(texts, server) -> str:
return texts.t(
'ADMIN_SQUAD_MIGRATION_SERVER_LABEL',
'{name} — 👥 {users} ({status})',
).format(name=server.display_name, users=server.current_users, status=status)
).format(name=html.escape(server.display_name), users=server.current_users, status=status)
def _build_migration_keyboard(
+9 -9
View File
@@ -44,8 +44,8 @@ def _build_server_edit_view(server):
<b>Информация:</b>
ID: {server.id}
UUID: <code>{server.squad_uuid}</code>
Название: {server.display_name}
Оригинальное: {server.original_name or 'Не указано'}
Название: {html.escape(server.display_name)}
Оригинальное: {html.escape(server.original_name) if server.original_name else 'Не указано'}
Статус: {status_emoji}
<b>Настройки:</b>
@@ -172,7 +172,7 @@ async def show_servers_list(callback: types.CallbackQuery, db_user: User, db: As
status_emoji = '' if server.is_available else ''
price_text = f'{int(server.price_rubles)}' if server.price_kopeks > 0 else 'Бесплатно'
text += f'{i}. {status_emoji} {server.display_name}\n'
text += f'{i}. {status_emoji} {html.escape(server.display_name)}\n'
text += f' 💰 Цена: {price_text}'
if server.max_users:
@@ -559,7 +559,7 @@ async def start_server_edit_name(callback: types.CallbackQuery, state: FSMContex
await callback.message.edit_text(
f'✏️ <b>Редактирование названия</b>\n\n'
f'Текущее название: <b>{server.display_name}</b>\n\n'
f'Текущее название: <b>{html.escape(server.display_name)}</b>\n\n'
f'Отправьте новое название для сервера:',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
@@ -621,7 +621,7 @@ async def delete_server_confirm(callback: types.CallbackQuery, db_user: User, db
🗑 <b>Удаление сервера</b>
Вы действительно хотите удалить сервер:
<b>{server.display_name}</b>
<b>{html.escape(server.display_name)}</b>
<b>Внимание!</b>
Сервер можно удалить только если к нему нет активных подключений.
@@ -658,7 +658,7 @@ async def delete_server_execute(callback: types.CallbackQuery, db_user: User, db
await cache.delete_pattern('available_countries*')
await callback.message.edit_text(
f'✅ Сервер <b>{server.display_name}</b> успешно удален!',
f'✅ Сервер <b>{html.escape(server.display_name)}</b> успешно удален!',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='📋 К списку серверов', callback_data='admin_servers_list')]
@@ -668,7 +668,7 @@ async def delete_server_execute(callback: types.CallbackQuery, db_user: User, db
)
else:
await callback.message.edit_text(
f'❌ Не удалось удалить сервер <b>{server.display_name}</b>\n\nВозможно, к нему есть активные подключения.',
f'❌ Не удалось удалить сервер <b>{html.escape(server.display_name)}</b>\n\nВозможно, к нему есть активные подключения.',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='🔙 К серверу', callback_data=f'admin_server_edit_{server_id}')]
@@ -706,7 +706,7 @@ async def show_server_detailed_stats(callback: types.CallbackQuery, db_user: Use
for i, server in enumerate(sorted_servers[:5], 1):
price_text = f'{int(server.price_rubles)}' if server.price_kopeks > 0 else 'Бесплатно'
text += f'{i}. {server.display_name} - {price_text}\n'
text += f'{i}. {html.escape(server.display_name)} - {price_text}\n'
if not sorted_servers:
text += 'Нет доступных серверов\n'
@@ -968,7 +968,7 @@ async def start_server_edit_promo_groups(
text = (
'🎯 <b>Настройка промогрупп</b>\n\n'
f'Сервер: <b>{server.display_name}</b>\n\n'
f'Сервер: <b>{html.escape(server.display_name)}</b>\n\n'
'Выберите промогруппы, которым будет доступен этот сервер.\n'
'Должна быть выбрана минимум одна промогруппа.'
)
+70 -66
View File
@@ -22,6 +22,7 @@ from app.database.models import Tariff, User
from app.localization.texts import get_texts
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.formatting import format_period, format_price_kopeks, format_traffic
logger = structlog.get_logger(__name__)
@@ -29,34 +30,6 @@ logger = structlog.get_logger(__name__)
ITEMS_PER_PAGE = 10
def _format_traffic(gb: int) -> str:
"""Форматирует трафик."""
if gb == 0:
return 'Безлимит'
return f'{gb} ГБ'
def _format_price_kopeks(kopeks: int) -> str:
"""Форматирует цену из копеек в рубли."""
rubles = kopeks / 100
if rubles == int(rubles):
return f'{int(rubles)}'
return f'{rubles:.2f}'
def _format_period(days: int) -> str:
"""Форматирует период."""
if days == 1:
return '1 день'
if days < 5:
return f'{days} дня'
if days < 21 or days % 10 >= 5 or days % 10 == 0:
return f'{days} дней'
if days % 10 == 1:
return f'{days} день'
return f'{days} дня'
def _parse_period_prices(text: str) -> dict[str, int]:
"""
Парсит строку с ценами периодов.
@@ -94,7 +67,7 @@ def _format_period_prices_display(prices: dict[str, int]) -> str:
for period_str in sorted(prices.keys(), key=int):
period = int(period_str)
price = prices[period_str]
lines.append(f'{_format_period(period)}: {_format_price_kopeks(price)}')
lines.append(f'{format_period(period)}: {format_price_kopeks(price)}')
return '\n'.join(lines)
@@ -278,7 +251,7 @@ def _format_traffic_topup_packages(tariff: Tariff) -> str:
lines = ['✅ Включено']
for gb in sorted(packages.keys()):
price = packages[gb]
lines.append(f'{gb} ГБ: {_format_price_kopeks(price)}')
lines.append(f'{gb} ГБ: {format_price_kopeks(price)}')
return '\n'.join(lines)
@@ -288,7 +261,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
get_texts(language)
status = '✅ Активен' if tariff.is_active else '❌ Неактивен'
traffic = _format_traffic(tariff.traffic_limit_gb)
traffic = format_traffic(tariff.traffic_limit_gb)
prices_display = _format_period_prices_display(tariff.period_prices or {})
# Форматируем список серверов
@@ -314,7 +287,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
# Форматируем цену за устройство
device_price = getattr(tariff, 'device_price_kopeks', None)
if device_price is not None and device_price > 0:
device_price_display = _format_price_kopeks(device_price) + '/мес'
device_price_display = format_price_kopeks(device_price) + '/мес'
else:
device_price_display = 'Недоступно'
@@ -338,7 +311,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
# Формируем блок цен в зависимости от типа тарифа
if is_daily:
price_block = f'<b>💰 Суточная цена:</b> {_format_price_kopeks(daily_price_kopeks)}/день'
price_block = f'<b>💰 Суточная цена:</b> {format_price_kopeks(daily_price_kopeks)}/день'
tariff_type = '🔄 Суточный'
else:
price_block = f'<b>Цены:</b>\n{prices_display}'
@@ -619,7 +592,7 @@ async def start_edit_daily_price(
await callback.message.edit_text(
f'💰 <b>Редактирование суточной цены</b>\n\n'
f'Тариф: {tariff.name}\n'
f'Текущая цена: {_format_price_kopeks(current_price)}/день\n\n'
f'Текущая цена: {format_price_kopeks(current_price)}/день\n\n'
'Введите новую цену за день в рублях.\n'
'Пример: <code>50</code> или <code>99.90</code>',
reply_markup=InlineKeyboardMarkup(
@@ -698,7 +671,7 @@ async def process_daily_price_input(
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
await message.answer(
f'✅ Суточная цена установлена: {_format_price_kopeks(price_kopeks)}/день\n\n'
f'✅ Суточная цена установлена: {format_price_kopeks(price_kopeks)}/день\n\n'
+ format_tariff_info(tariff, db_user.language, subs_count),
reply_markup=get_tariff_view_keyboard(tariff, db_user.language),
parse_mode='HTML',
@@ -793,7 +766,7 @@ async def process_tariff_traffic(
await state.update_data(tariff_traffic=traffic)
await state.set_state(AdminStates.creating_tariff_devices)
traffic_display = _format_traffic(traffic)
traffic_display = format_traffic(traffic)
await message.answer(
'📦 <b>Создание тарифа</b>\n\n'
@@ -831,7 +804,7 @@ async def process_tariff_devices(
await state.update_data(tariff_devices=devices)
await state.set_state(AdminStates.creating_tariff_tier)
traffic_display = _format_traffic(data['tariff_traffic'])
traffic_display = format_traffic(data['tariff_traffic'])
await message.answer(
'📦 <b>Создание тарифа</b>\n\n'
@@ -871,7 +844,7 @@ async def process_tariff_tier(
data = await state.get_data()
await state.update_data(tariff_tier=tier)
traffic_display = _format_traffic(data['tariff_traffic'])
traffic_display = format_traffic(data['tariff_traffic'])
# Шаг 5/6: Выбор типа тарифа
await message.answer(
@@ -907,7 +880,7 @@ async def select_tariff_type_periodic(
await state.update_data(tariff_is_daily=False)
await state.set_state(AdminStates.creating_tariff_prices)
traffic_display = _format_traffic(data['tariff_traffic'])
traffic_display = format_traffic(data['tariff_traffic'])
await callback.message.edit_text(
'📦 <b>Создание тарифа</b>\n\n'
@@ -945,7 +918,7 @@ async def select_tariff_type_daily(
await state.update_data(tariff_is_daily=True)
await state.set_state(AdminStates.editing_tariff_daily_price)
traffic_display = _format_traffic(data['tariff_traffic'])
traffic_display = format_traffic(data['tariff_traffic'])
await callback.message.edit_text(
'📦 <b>Создание суточного тарифа</b>\n\n'
@@ -989,7 +962,7 @@ async def process_tariff_prices(
data = await state.get_data()
await state.update_data(tariff_prices=prices)
_format_traffic(data['tariff_traffic'])
format_traffic(data['tariff_traffic'])
_format_period_prices_display(prices)
# Создаем тариф
@@ -1170,7 +1143,7 @@ async def start_edit_tariff_traffic(
await state.set_state(AdminStates.editing_tariff_traffic)
await state.update_data(tariff_id=tariff_id, language=db_user.language)
current_traffic = _format_traffic(tariff.traffic_limit_gb)
current_traffic = format_traffic(tariff.traffic_limit_gb)
await callback.message.edit_text(
f'📊 <b>Редактирование трафика</b>\n\n'
@@ -1462,7 +1435,7 @@ async def start_edit_tariff_device_price(
device_price = getattr(tariff, 'device_price_kopeks', None)
if device_price is not None and device_price > 0:
current_price = _format_price_kopeks(device_price) + '/мес'
current_price = format_price_kopeks(device_price) + '/мес'
else:
current_price = 'Недоступно (докупка устройств запрещена)'
@@ -1782,7 +1755,7 @@ async def start_edit_tariff_traffic_topup(
status = '✅ Включено'
if packages:
packages_display = '\n'.join(
f'{gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
f'{gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items())
)
else:
packages_display = ' Пакеты не настроены'
@@ -1871,7 +1844,7 @@ async def toggle_tariff_traffic_topup(
status = '✅ Включено'
if packages:
packages_display = '\n'.join(
f'{gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
f'{gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items())
)
else:
packages_display = ' Пакеты не настроены'
@@ -1951,7 +1924,7 @@ async def start_edit_traffic_topup_packages(
if packages:
packages_display = '\n'.join(
f'{gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
f'{gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items())
)
else:
packages_display = ' Не настроены'
@@ -2020,9 +1993,7 @@ async def process_edit_traffic_topup_packages(
# Показываем обновленное меню
texts = get_texts(db_user.language)
packages_display = '\n'.join(
f'{gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
)
packages_display = '\n'.join(f'{gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items()))
max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
max_limit_display = f'{max_topup_traffic} ГБ' if max_topup_traffic > 0 else 'Без ограничений'
@@ -2136,7 +2107,7 @@ async def process_edit_max_topup_traffic(
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
if packages:
packages_display = '\n'.join(
f'{gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
f'{gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items())
)
else:
packages_display = ' Пакеты не настроены'
@@ -2275,7 +2246,7 @@ async def start_edit_tariff_squads(
await callback.answer('Тариф не найден', show_alert=True)
return
squads, _ = await get_all_server_squads(db)
squads, _ = await get_all_server_squads(db, limit=10000)
if not squads:
await callback.answer('Нет доступных серверов', show_alert=True)
@@ -2291,7 +2262,7 @@ async def start_edit_tariff_squads(
[
InlineKeyboardButton(
text=f'{prefix} {squad.display_name}',
callback_data=f'admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}',
callback_data=f'trf_sq:{tariff_id}:{squad.squad_uuid}',
)
]
)
@@ -2344,7 +2315,7 @@ async def toggle_tariff_squad(
tariff = await update_tariff(db, tariff, allowed_squads=list(current_squads))
# Перерисовываем меню
squads, _ = await get_all_server_squads(db)
squads, _ = await get_all_server_squads(db, limit=10000)
texts = get_texts(db_user.language)
buttons = []
@@ -2355,7 +2326,7 @@ async def toggle_tariff_squad(
[
InlineKeyboardButton(
text=f'{prefix} {squad.display_name}',
callback_data=f'admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}',
callback_data=f'trf_sq:{tariff_id}:{squad.squad_uuid}',
)
]
)
@@ -2382,6 +2353,15 @@ async def toggle_tariff_squad(
await callback.answer()
# Применяем изменения серверов к существующим подпискам
from app.services.subscription_service import SubscriptionService
propagate_result = await SubscriptionService().propagate_tariff_squads(db, tariff.id, list(current_squads))
if propagate_result.failed_ids:
await callback.message.answer(
f'⚠️ {len(propagate_result.failed_ids)} из {propagate_result.total} подписок не синхронизированы с RemnaWave',
)
@admin_required
@error_handler
@@ -2402,7 +2382,7 @@ async def clear_tariff_squads(
await callback.answer('Все серверы очищены')
# Перерисовываем меню
squads, _ = await get_all_server_squads(db)
squads, _ = await get_all_server_squads(db, limit=10000)
texts = get_texts(db_user.language)
buttons = []
@@ -2411,7 +2391,7 @@ async def clear_tariff_squads(
[
InlineKeyboardButton(
text=f'{squad.display_name}',
callback_data=f'admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}',
callback_data=f'trf_sq:{tariff_id}:{squad.squad_uuid}',
)
]
)
@@ -2436,6 +2416,15 @@ async def clear_tariff_squads(
except TelegramBadRequest:
pass
# Применяем изменения серверов к существующим подпискам (пустой список = все серверы)
from app.services.subscription_service import SubscriptionService
propagate_result = await SubscriptionService().propagate_tariff_squads(db, tariff.id, [])
if propagate_result.failed_ids:
await callback.message.answer(
f'⚠️ {len(propagate_result.failed_ids)} из {propagate_result.total} подписок не синхронизированы с RemnaWave',
)
@admin_required
@error_handler
@@ -2452,8 +2441,8 @@ async def select_all_tariff_squads(
await callback.answer('Тариф не найден', show_alert=True)
return
squads, _ = await get_all_server_squads(db)
all_uuids = [s.squad_uuid for s in squads]
squads, _ = await get_all_server_squads(db, limit=10000)
all_uuids = [s.squad_uuid for s in squads if s.squad_uuid]
tariff = await update_tariff(db, tariff, allowed_squads=all_uuids)
await callback.answer('Все серверы выбраны')
@@ -2466,7 +2455,7 @@ async def select_all_tariff_squads(
[
InlineKeyboardButton(
text=f'{squad.display_name}',
callback_data=f'admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}',
callback_data=f'trf_sq:{tariff_id}:{squad.squad_uuid}',
)
]
)
@@ -2491,6 +2480,15 @@ async def select_all_tariff_squads(
except TelegramBadRequest:
pass
# Применяем изменения серверов к существующим подпискам
from app.services.subscription_service import SubscriptionService
propagate_result = await SubscriptionService().propagate_tariff_squads(db, tariff.id, all_uuids)
if propagate_result.failed_ids:
await callback.message.answer(
f'⚠️ {len(propagate_result.failed_ids)} из {propagate_result.total} подписок не синхронизированы с RemnaWave',
)
# ============ РЕДАКТИРОВАНИЕ ПРОМОГРУПП ============
@@ -2799,7 +2797,13 @@ def register_handlers(dp: Dispatcher):
# Просмотр и переключение
dp.callback_query.register(view_tariff, F.data.startswith('admin_tariff_view:'))
dp.callback_query.register(
toggle_tariff, F.data.startswith('admin_tariff_toggle:') & ~F.data.startswith('admin_tariff_toggle_trial:')
toggle_tariff,
F.data.startswith('admin_tariff_toggle:')
& ~F.data.startswith('admin_tariff_toggle_trial:')
& ~F.data.startswith('trf_sq:')
& ~F.data.startswith('admin_tariff_toggle_promo:')
& ~F.data.startswith('admin_tariff_toggle_traffic_topup:')
& ~F.data.startswith('admin_tariff_toggle_daily:'),
)
dp.callback_query.register(toggle_trial_tariff, F.data.startswith('admin_tariff_toggle_trial:'))
@@ -2821,7 +2825,8 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(start_edit_tariff_description, F.data.startswith('admin_tariff_edit_desc:'))
dp.message.register(process_edit_tariff_description, AdminStates.editing_tariff_description)
# Редактирование трафика
# Редактирование трафика (traffic_topup BEFORE traffic to avoid prefix conflict)
dp.callback_query.register(start_edit_tariff_traffic_topup, F.data.startswith('admin_tariff_edit_traffic_topup:'))
dp.callback_query.register(start_edit_tariff_traffic, F.data.startswith('admin_tariff_edit_traffic:'))
dp.message.register(process_edit_tariff_traffic, AdminStates.editing_tariff_traffic)
@@ -2849,8 +2854,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(start_edit_tariff_trial_days, F.data.startswith('admin_tariff_edit_trial_days:'))
dp.message.register(process_edit_tariff_trial_days, AdminStates.editing_tariff_trial_days)
# Редактирование докупки трафика
dp.callback_query.register(start_edit_tariff_traffic_topup, F.data.startswith('admin_tariff_edit_traffic_topup:'))
# Редактирование докупки трафика (start_edit_tariff_traffic_topup registered above with traffic)
dp.callback_query.register(toggle_tariff_traffic_topup, F.data.startswith('admin_tariff_toggle_traffic_topup:'))
dp.callback_query.register(
start_edit_traffic_topup_packages, F.data.startswith('admin_tariff_edit_topup_packages:')
@@ -2861,13 +2865,13 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(start_edit_max_topup_traffic, F.data.startswith('admin_tariff_edit_max_topup:'))
dp.message.register(process_edit_max_topup_traffic, AdminStates.editing_tariff_max_topup_traffic)
# Удаление
dp.callback_query.register(confirm_delete_tariff, F.data.startswith('admin_tariff_delete:'))
# Удаление (delete_confirm BEFORE delete to avoid prefix conflict)
dp.callback_query.register(delete_tariff_confirmed, F.data.startswith('admin_tariff_delete_confirm:'))
dp.callback_query.register(confirm_delete_tariff, F.data.startswith('admin_tariff_delete:'))
# Редактирование серверов
dp.callback_query.register(start_edit_tariff_squads, F.data.startswith('admin_tariff_edit_squads:'))
dp.callback_query.register(toggle_tariff_squad, F.data.startswith('admin_tariff_toggle_squad:'))
dp.callback_query.register(toggle_tariff_squad, F.data.startswith('trf_sq:'))
dp.callback_query.register(clear_tariff_squads, F.data.startswith('admin_tariff_clear_squads:'))
dp.callback_query.register(select_all_tariff_squads, F.data.startswith('admin_tariff_select_all_squads:'))
+1 -1
View File
@@ -81,7 +81,7 @@ def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int
if current.strip():
pages.append(current)
return pages if pages else [header]
return pages or [header]
async def show_admin_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
+70 -52
View File
@@ -1,3 +1,4 @@
import html
import re
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
@@ -19,7 +20,6 @@ from app.database.crud.campaign import (
from app.database.crud.promo_group import get_promo_groups_with_counts
from app.database.crud.server_squad import (
get_all_server_squads,
get_server_ids_by_uuids,
get_server_squad_by_id,
get_server_squad_by_uuid,
)
@@ -869,7 +869,7 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
try:
server = await get_server_squad_by_uuid(db, squad_uuid)
if server:
text += f'{server.display_name}\n'
text += f'{html.escape(server.display_name)}\n'
else:
text += f'{squad_uuid[:8]}... (неизвестный)\n'
except Exception as e:
@@ -1019,9 +1019,9 @@ async def delete_user_account(callback: types.CallbackQuery, db_user: User, db:
user_id = int(callback.data.split('_')[-1])
user_service = UserService()
success = await user_service.delete_user_account(db, user_id, db_user.id)
delete_result = await user_service.delete_user_account(db, user_id, db_user.id)
if success:
if delete_result.bot_deleted:
await callback.message.edit_text(
'✅ Пользователь успешно удален',
reply_markup=types.InlineKeyboardMarkup(
@@ -1210,7 +1210,7 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
end_date=format_datetime(subscription.end_date),
traffic=traffic_usage,
devices=subscription.device_limit,
countries=len(subscription.connected_squads),
countries=len(subscription.connected_squads or []),
)
)
else:
@@ -2712,7 +2712,7 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db:
text += f'• Статус: {sub_status}{sub_type}\n'
text += f'• Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ\n'
text += f'• Устройства: {subscription.device_limit}\n'
text += f'• Стран: {len(subscription.connected_squads)}\n'
text += f'• Стран: {len(subscription.connected_squads or [])}\n'
else:
text += '• Отсутствует\n'
@@ -4003,12 +4003,20 @@ async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, adm
else:
await add_subscription_traffic(db, subscription, gb)
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
await reactivate_subscription(db, subscription)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if subscription.status == 'active':
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if user and user.remnawave_uuid:
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
traffic_text = 'безлимитный' if gb == 0 else f'{gb} ГБ'
logger.info('Админ добавил трафик пользователю', admin_id=admin_id, traffic_text=traffic_text, user_id=user_id)
return True
@@ -4023,7 +4031,6 @@ async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id
from app.database.crud.subscription import (
deactivate_subscription,
get_subscription_by_user_id,
is_active_paid_subscription,
)
from app.services.subscription_service import SubscriptionService
@@ -4032,13 +4039,6 @@ async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id
logger.error('Подписка не найдена для пользователя', user_id=user_id)
return False
if is_active_paid_subscription(subscription):
logger.info(
'⏭️ Пропуск деактивации: у пользователя активная оплаченная подписка',
user_id=user_id,
)
return False
await deactivate_subscription(db, subscription)
user = await get_user_by_id(db, user_id)
@@ -4173,46 +4173,22 @@ async def _calculate_subscription_period_price(
subscription_service: SubscriptionService | None = None,
) -> int:
"""Рассчитывает стоимость подписки для администратора с учётом всех параметров."""
from app.services.pricing_engine import pricing_engine
service = subscription_service or SubscriptionService()
connected_squads = list(subscription.connected_squads or [])
server_ids = []
if connected_squads:
# Загружаем тариф для корректного расчёта в тарифном режиме
if subscription.tariff_id:
try:
server_ids = await get_server_ids_by_uuids(db, connected_squads)
if len(server_ids) != len(connected_squads):
logger.warning(
'Не удалось сопоставить все сервера подписки пользователя для расчёта цены',
telegram_id=target_user.telegram_id,
)
await db.refresh(subscription, ['tariff'])
except Exception as e:
logger.error(
'Не удалось получить идентификаторы серверов для расчёта цены подписки пользователя',
telegram_id=target_user.telegram_id,
e=e,
)
server_ids = []
traffic_limit_gb = subscription.traffic_limit_gb
if traffic_limit_gb is None:
traffic_limit_gb = settings.DEFAULT_TRAFFIC_LIMIT_GB
logger.warning('Не удалось загрузить тариф для расчёта цены', error=e)
device_limit = subscription.device_limit
if not device_limit or device_limit < 0:
device_limit = settings.DEFAULT_DEVICE_LIMIT
total_price, _ = await service.calculate_subscription_price(
period_days=period_days,
traffic_gb=traffic_limit_gb,
server_squad_ids=server_ids,
devices=device_limit,
db=db,
pricing = await pricing_engine.calculate_renewal_price(
db,
subscription,
period_days,
user=target_user,
promo_group=target_user.promo_group,
)
return total_price
return pricing.final_total
@admin_required
@@ -4579,6 +4555,13 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
# Загружаем tariff для внешнего сквада
try:
await db.refresh(subscription, ['tariff'])
except Exception:
pass
ext_squad_uuid = subscription.tariff.external_squad_uuid if subscription.tariff else None
if target_user.remnawave_uuid:
async with remnawave_service.get_api_client() as api:
update_kwargs = dict(
@@ -4602,6 +4585,12 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
# Внешний сквад: синхронизируем из тарифа или сбрасываем
if ext_squad_uuid is not None:
update_kwargs['external_squad_uuid'] = ext_squad_uuid
else:
update_kwargs['external_squad_uuid'] = None
remnawave_user = await api.update_user(**update_kwargs)
else:
username = settings.format_remnawave_username(
@@ -4633,6 +4622,8 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
if hwid_limit is not None:
create_kwargs['hwid_device_limit'] = hwid_limit
if ext_squad_uuid is not None:
create_kwargs['external_squad_uuid'] = ext_squad_uuid
remnawave_user = await api.create_user(**create_kwargs)
@@ -5326,9 +5317,24 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
try:
old_tariff_id = subscription.tariff_id
# Обновляем параметры подписки в соответствии с тарифом
# Preserve extra purchased devices above the old tariff's base limit
extra_devices = 0
if subscription.tariff_id:
old_tariff = await get_tariff_by_id(db, subscription.tariff_id)
if old_tariff and old_tariff.device_limit:
extra_devices = max(0, (subscription.device_limit or old_tariff.device_limit) - old_tariff.device_limit)
subscription.tariff_id = tariff.id
subscription.device_limit = tariff.device_limit
new_base = tariff.device_limit or 1
new_total = new_base + extra_devices
effective_max = tariff.max_device_limit or (
settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
)
if effective_max and new_total > effective_max:
new_total = effective_max
subscription.device_limit = new_total
subscription.traffic_limit_gb = tariff.traffic_limit_gb
subscription.connected_squads = tariff.allowed_squads or []
subscription.updated_at = datetime.now(UTC)
@@ -5346,6 +5352,18 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
subscription.traffic_used_gb = 0.0
# Записываем транзакцию о смене тарифа
from app.database.crud.transaction import create_transaction
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=0,
description=f"Смена тарифа администратором на '{tariff.name}'",
commit=False,
)
await db.commit()
# Синхронизируем с RemnaWave (сброс трафика по админ-настройке)
@@ -5369,7 +5387,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
await callback.message.edit_text(
f'✅ <b>Тариф успешно изменен</b>\n\n'
f'Новый тариф: <b>{tariff.name}</b>\n'
f'• Устройства: {tariff.device_limit}\n'
f'• Устройства: {subscription.device_limit}\n'
f'• Трафик: {"♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"}\n'
f'• Серверы: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}',
reply_markup=types.InlineKeyboardMarkup(
+2 -2
View File
@@ -21,8 +21,8 @@ logger = structlog.get_logger(__name__)
FREEKASSA_SUB_METHODS = {
'freekassa_sbp': {'payment_system_id': 44, 'get_name': lambda: settings.get_freekassa_sbp_display_name()},
'freekassa_card': {'payment_system_id': 36, 'get_name': lambda: settings.get_freekassa_card_display_name()},
'freekassa_sbp': {'payment_system_id': 44, 'get_name': settings.get_freekassa_sbp_display_name},
'freekassa_card': {'payment_system_id': 36, 'get_name': settings.get_freekassa_card_display_name},
}
+117 -128
View File
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.transaction import get_user_transactions
from app.database.models import TransactionType, User
from app.handlers.subscription.autopay import handle_confirm_unlink, handle_saved_cards_list, handle_unlink_card
from app.keyboards.inline import (
get_back_keyboard,
get_balance_keyboard,
@@ -138,6 +139,13 @@ async def route_payment_by_method(
await process_kassa_ai_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'riopay':
from .riopay import process_riopay_payment_amount
async with AsyncSessionLocal() as db:
await process_riopay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
return False
@@ -145,6 +153,8 @@ async def get_quick_amount_buttons(language: str, user: User) -> list:
"""
Generate quick amount buttons with user-specific pricing and discounts.
Includes full subscription cost: base period price + devices + servers + traffic.
Args:
language: User's language for formatting
user: User object to calculate personalized discounts
@@ -156,25 +166,63 @@ async def get_quick_amount_buttons(language: str, user: User) -> list:
return []
from app.config import PERIOD_PRICES
from app.localization.texts import get_texts
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.database import AsyncSessionLocal
from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days
texts = get_texts(language)
# В режиме тарифов получаем цены из тарифа пользователя
tariff = None
tariff_prices = None
tariff_periods = None
if settings.is_tariffs_mode():
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.tariff import get_tariff_by_id
from app.database.database import AsyncSessionLocal
devices_price_per_month = 0
servers_per_month_prices: list[int] = []
traffic_price_per_month = 0
async with AsyncSessionLocal() as db:
subscription = await get_subscription_by_user_id(db, user.id)
if subscription and subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.period_prices:
tariff_prices = {int(k): v for k, v in tariff.period_prices.items()}
tariff_periods = sorted(tariff_prices.keys())
async with AsyncSessionLocal() as db:
subscription = await get_subscription_by_user_id(db, user.id)
# В режиме тарифов получаем цены из тарифа пользователя
if settings.is_tariffs_mode() and subscription and subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.period_prices:
tariff_prices = {int(k): v for k, v in tariff.period_prices.items()}
tariff_periods = sorted(tariff_prices.keys())
# Получаем стоимость устройств, серверов и трафика из подписки
if subscription and not subscription.is_trial:
# Устройства: в режиме тарифов используем цену и базовый лимит из тарифа
if settings.is_tariffs_mode() and tariff and tariff_prices:
tariff_device_price = getattr(tariff, 'device_price_kopeks', None)
if tariff_device_price and tariff_device_price > 0:
device_unit_price = tariff_device_price
base_device_limit = tariff.device_limit or 0
else:
device_unit_price = settings.PRICE_PER_DEVICE
base_device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_unit_price = settings.PRICE_PER_DEVICE
base_device_limit = settings.DEFAULT_DEVICE_LIMIT
device_limit = subscription.device_limit or base_device_limit
additional_devices = max(0, device_limit - base_device_limit)
if additional_devices > 0:
devices_price_per_month = additional_devices * device_unit_price
# Серверы
connected_squads = subscription.connected_squads or []
if connected_squads:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
_, servers_per_month_prices = await subscription_service.get_countries_price_by_uuids(
connected_squads, db, promo_group_id=user.promo_group_id
)
# Трафик
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
buttons = []
@@ -192,23 +240,58 @@ async def get_quick_amount_buttons(language: str, user: User) -> list:
base_price_kopeks = PERIOD_PRICES.get(period, 0)
if base_price_kopeks > 0:
# Calculate price with user's promo group discount using unified system
# Базовая цена периода с промо-скидками
price_info = calculate_user_price(user, base_price_kopeks, period, 'period')
callback_data = f'quick_amount_{price_info.final_price}'
months = calculate_months_from_days(period)
# Стоимость устройств со скидкой
devices_addon = 0
if devices_price_per_month > 0:
devices_discount = user.get_promo_discount('devices', period)
devices_discounted, _ = apply_percentage_discount(devices_price_per_month, devices_discount)
devices_addon = devices_discounted * months
# Стоимость серверов со скидкой
servers_addon = 0
if servers_per_month_prices:
servers_discount = user.get_promo_discount('servers', period)
for server_price in servers_per_month_prices:
discounted, _ = apply_percentage_discount(server_price, servers_discount)
servers_addon += discounted
servers_addon *= months
# Стоимость трафика со скидкой
traffic_addon = 0
if traffic_price_per_month > 0:
traffic_discount = user.get_promo_discount('traffic', period)
traffic_discounted, _ = apply_percentage_discount(traffic_price_per_month, traffic_discount)
traffic_addon = traffic_discounted * months
total_price = price_info.final_price + devices_addon + servers_addon + traffic_addon
callback_data = f'quick_amount_{total_price}'
# Format button text with discount display
period_label = f'{period} дней'
# For balance buttons, use simpler format without emoji and period label prefix
if price_info.has_discount:
button_text = (
f'{texts.format_price(price_info.base_price)}'
f'{texts.format_price(price_info.final_price)} '
f'(-{price_info.discount_percent}%) • {period_label}'
)
# Скидка считается от полной базовой стоимости (период + аддоны без скидок)
total_base = (
base_price_kopeks
+ (devices_price_per_month + sum(servers_per_month_prices) + traffic_price_per_month) * months
)
has_discount = total_base > total_price and total_base > 0
if has_discount:
discount_pct = round((total_base - total_price) * 100 / total_base)
if discount_pct > 0:
button_text = (
f'{texts.format_price(total_base)}'
f'{texts.format_price(total_price)} '
f'(-{discount_pct}%) • {period_label}'
)
else:
button_text = f'{texts.format_price(total_price)}{period_label}'
else:
button_text = f'{texts.format_price(price_info.final_price)}{period_label}'
button_text = f'{texts.format_price(total_price)}{period_label}'
buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data))
@@ -322,10 +405,7 @@ async def handle_balance_history_pagination(callback: types.CallbackQuery, db_us
@error_handler
async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
from app.config import settings
from app.database.crud.subscription import get_subscription_by_user_id
from app.services.subscription_service import SubscriptionService
from app.utils.payment_utils import get_payment_methods_text
from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days
texts = get_texts(db_user.language)
@@ -348,107 +428,7 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
payment_text = get_payment_methods_text(db_user.language)
# Добавляем информацию о текущем тарифе пользователя
subscription = await get_subscription_by_user_id(db, db_user.id)
tariff_info = ''
if subscription and not subscription.is_trial:
# Рассчитываем приблизительную стоимость продления на 30 дней
duration_days = 30 # Берем для примера 30 дней
current_traffic = subscription.traffic_limit_gb
current_connected_squads = subscription.connected_squads or []
current_device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
try:
# Получаем цены для текущих параметров
from app.config import PERIOD_PRICES
from app.database.crud.tariff import get_tariff_by_id
# В режиме тарифов берём цену из тарифа пользователя
base_price_original = 0
if settings.is_tariffs_mode() and subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.period_prices:
base_price_original = tariff.period_prices.get(str(duration_days), 0)
# Если не нашли в тарифе - используем PERIOD_PRICES
if base_price_original <= 0:
base_price_original = PERIOD_PRICES.get(duration_days, 0)
period_discount_percent = db_user.get_promo_discount('period', duration_days)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
period_discount_percent,
)
# Рассчитываем стоимость серверов
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
(
servers_price_per_month,
per_server_monthly_prices,
) = await subscription_service.get_countries_price_by_uuids(
current_connected_squads,
db,
promo_group_id=db_user.promo_group_id,
)
servers_discount_percent = db_user.get_promo_discount('servers', duration_days)
total_servers_price = 0
for server_price in per_server_monthly_prices:
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price,
servers_discount_percent,
)
total_servers_price += discounted_per_month
# Рассчитываем стоимость трафика
traffic_price_per_month = settings.get_traffic_price(current_traffic)
traffic_discount_percent = db_user.get_promo_discount('traffic', duration_days)
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
traffic_price_per_month,
traffic_discount_percent,
)
# Рассчитываем стоимость устройств
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount('devices', duration_days)
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
# Общая стоимость
months_in_period = calculate_months_from_days(duration_days)
total_price = (
base_price
+ total_servers_price * months_in_period
+ traffic_discounted_per_month * months_in_period
+ devices_discounted_per_month * months_in_period
)
traffic_value = current_traffic or 0
if traffic_value <= 0:
traffic_display = texts.t('TRAFFIC_UNLIMITED_SHORT', 'Безлимит')
else:
traffic_display = texts.format_traffic(traffic_value)
current_tariff_desc = (
f'📱 Подписка: {len(current_connected_squads)} серверов, '
f'{traffic_display}, {current_device_limit} устр.'
)
estimated_price_info = (
f'💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней'
)
tariff_info = f'\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}'
except Exception as e:
logger.warning(
'Не удалось рассчитать стоимость текущей подписки для пользователя', db_user_id=db_user.id, error=e
)
tariff_info = ''
full_text = payment_text + tariff_info
full_text = payment_text
keyboard = get_payment_methods_keyboard(0, db_user.language)
@@ -873,6 +853,11 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_kassa_ai_topup, F.data == 'topup_kassa_ai')
dp.callback_query.register(process_kassa_ai_quick_amount, F.data.startswith('topup_amount|kassa_ai|'))
from .riopay import process_riopay_quick_amount, start_riopay_topup
dp.callback_query.register(start_riopay_topup, F.data == 'topup_riopay')
dp.callback_query.register(process_riopay_quick_amount, F.data.startswith('topup_amount|riopay|'))
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
@@ -895,3 +880,7 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(handle_quick_amount_selection, F.data.startswith('quick_amount_'))
dp.callback_query.register(handle_topup_amount_callback, F.data.startswith('topup_amount|'))
dp.callback_query.register(handle_saved_cards_list, F.data == 'saved_cards_list')
dp.callback_query.register(handle_unlink_card, F.data.startswith('unlink_card_'))
dp.callback_query.register(handle_confirm_unlink, F.data.startswith('confirm_unlink_'))
+354
View File
@@ -0,0 +1,354 @@
"""Handler for RioPay balance top-up."""
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='🆘 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _create_riopay_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
):
"""
Common logic for creating RioPay 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_riopay_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_riopay_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'💳 Оплатить {amount}',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'RIOPAY_PAYMENT_CREATED',
'💳 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}₽</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('RioPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_riopay_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly (called from quick_amount handlers).
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
await message.answer(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
# Validate amount
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.RIOPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}',
).format(min_amount=min_amount // 100),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}',
).format(max_amount=max_amount // 100),
parse_mode='HTML',
)
return
await state.clear()
await _create_riopay_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_riopay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Start RioPay top-up process - ask for amount.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
await callback.message.edit_text(
f'🚫 <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='riopay')
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.RIOPAY_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_riopay_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'RIOPAY_ENTER_AMOUNT',
'💳 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\n'
'Максимум: {max_amount}',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
@error_handler
async def process_riopay_custom_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process custom amount input for RioPay payment.
"""
data = await state.get_data()
if data.get('payment_method') != 'riopay':
return
texts = get_texts(db_user.language)
try:
amount_text = message.text.replace(',', '.').replace(' ', '').strip()
amount_rubles = float(amount_text)
amount_kopeks = round(amount_rubles * 100)
except (ValueError, TypeError):
await message.answer(
texts.t(
'PAYMENT_INVALID_AMOUNT',
'Введите корректную сумму числом.',
),
parse_mode='HTML',
)
return
await process_riopay_payment_amount(
message=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
state=state,
)
@error_handler
async def process_riopay_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process quick amount selection for RioPay payment.
Called when user clicks a predefined amount button.
"""
texts = get_texts(db_user.language)
if not settings.is_riopay_enabled():
await callback.answer(
texts.t('RIOPAY_NOT_AVAILABLE', 'RioPay временно недоступен'),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|riopay|{amount_kopeks}
try:
parts = callback.data.split('|')
if len(parts) >= 3:
amount_kopeks = int(parts[2])
else:
await callback.answer('Invalid callback data', show_alert=True)
return
except (ValueError, IndexError):
await callback.answer('Invalid amount', show_alert=True)
return
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
# Validate amount
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.RIOPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await callback.answer(
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
show_alert=True,
)
return
if amount_kopeks > max_amount:
await callback.answer(
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
show_alert=True,
)
return
await callback.answer()
await state.clear()
await _create_riopay_payment_and_respond(
message_or_callback=callback.message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
)
+95
View File
@@ -0,0 +1,95 @@
"""Handler for gift subscription activation via inline callback button."""
import html as html_mod
import structlog
from aiogram import Dispatcher, F, types
from aiogram.types import InaccessibleMessage
from sqlalchemy import select
from app.database.database import AsyncSessionLocal
from app.database.models import GuestPurchase
from app.services.guest_purchase_service import GuestPurchaseError, activate_purchase
logger = structlog.get_logger(__name__)
_GIFT_NOT_FOUND = 'Подарок не найден или недоступен.'
async def handle_gift_activate(callback: types.CallbackQuery) -> None:
"""Handle gift_activate:{purchase_id} callback from Telegram notification."""
if isinstance(callback.message, InaccessibleMessage):
await callback.answer('Сообщение устарело. Попробуйте /start.', show_alert=True)
return
if not callback.data:
return
parts = callback.data.split(':', 1)
if len(parts) != 2:
await callback.answer(_GIFT_NOT_FOUND, show_alert=True)
return
try:
purchase_id = int(parts[1])
except ValueError:
await callback.answer(_GIFT_NOT_FOUND, show_alert=True)
return
await callback.answer()
await callback.message.edit_text('⏳ Активируем подарок...', parse_mode=None)
async with AsyncSessionLocal() as db:
result = await db.execute(select(GuestPurchase).where(GuestPurchase.id == purchase_id))
purchase = result.scalars().first()
if not purchase or purchase.user_id is None or purchase.user is None:
await callback.message.edit_text(_GIFT_NOT_FOUND, parse_mode=None)
return
# Verify the callback sender is the actual recipient
if purchase.user.telegram_id != callback.from_user.id:
await callback.message.edit_text(_GIFT_NOT_FOUND, parse_mode=None)
return
# Resolve tariff info inside session (selectin-loaded relationships)
tariff_name = html_mod.escape(purchase.tariff.name) if purchase.tariff and purchase.tariff.name else ''
period_days = purchase.period_days
try:
await activate_purchase(db, purchase.token, skip_notification=True)
except GuestPurchaseError as exc:
logger.warning(
'Gift activation via callback failed',
purchase_id=purchase_id,
telegram_id=callback.from_user.id,
error=exc.message,
)
if exc.status_code >= 500:
await callback.message.edit_text('Произошла ошибка при активации. Попробуйте позже.', parse_mode=None)
else:
await callback.message.edit_text(
f'Не удалось активировать подарок: {html_mod.escape(exc.message)}',
parse_mode=None,
)
return
except Exception:
logger.exception(
'Unexpected error during gift activation via callback',
purchase_id=purchase_id,
telegram_id=callback.from_user.id,
)
await callback.message.edit_text('Произошла ошибка при активации. Попробуйте позже.', parse_mode=None)
return
period_text = f'{period_days} дн.' if period_days else ''
tariff_text = f'{tariff_name}{period_text}' if tariff_name else period_text
await callback.message.edit_text(
f'✅ <b>Подарок активирован!</b>\n{tariff_text}\n\nВаша подписка обновлена.',
)
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(handle_gift_activate, F.data.startswith('gift_activate:'))
+50 -23
View File
@@ -1089,6 +1089,9 @@ def _get_subscription_status(user: User, texts, is_daily_tariff: bool = False) -
if actual_status == 'disabled':
return texts.t('SUB_STATUS_DISABLED', '⚫ Отключена')
if actual_status == 'limited':
return texts.t('SUB_STATUS_LIMITED', '⚠️ Трафик исчерпан')
if actual_status == 'expired':
return texts.t(
'SUB_STATUS_EXPIRED',
@@ -1294,34 +1297,55 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
# Найти максимальный период <= баланса
best_period = None
best_price = 0
best_pricing = None # Cache pricing result for reuse in finalize()
for period in available_periods:
price, _ = await subscription_service.calculate_subscription_price_with_months(
period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
)
if price <= balance:
best_period = period
best_price = price
break
# Для продления используем PricingEngine (единый расчёт для всех поверхностей).
from app.services.pricing_engine import pricing_engine
if not best_period:
# Показать сколько не хватает для минимального периода
min_period = min(available_periods) if available_periods else 30
min_price, _ = await subscription_service.calculate_subscription_price_with_months(
min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
)
missing = min_price - balance
await callback.answer(
texts.t('INSUFFICIENT_FUNDS_DETAILED', f'❌ Недостаточно средств. Не хватает {missing // 100}'),
show_alert=True,
)
renewal_service = SubscriptionRenewalService() if subscription else None
try:
for period in available_periods:
if subscription:
pricing_result = await pricing_engine.calculate_renewal_price(db, subscription, period, user=db_user)
price = pricing_result.final_total
else:
price, _ = await subscription_service.calculate_subscription_price_with_months(
period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
)
if price <= balance:
best_period = period
best_price = price
best_pricing = pricing_result if subscription else None
break
if not best_period:
# Показать сколько не хватает для минимального периода
min_period = min(available_periods) if available_periods else 30
if subscription:
min_pricing = await pricing_engine.calculate_renewal_price(db, subscription, min_period, user=db_user)
min_price = min_pricing.final_total
else:
min_price, _ = await subscription_service.calculate_subscription_price_with_months(
min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
)
missing = min_price - balance
await callback.answer(
texts.t('INSUFFICIENT_FUNDS_DETAILED', f'❌ Недостаточно средств. Не хватает {missing // 100}'),
show_alert=True,
)
return
except Exception as e:
logger.error('Ошибка расчёта стоимости при активации', error=e)
await callback.answer('❌ Ошибка расчёта стоимости', show_alert=True)
return
try:
if subscription:
# Продление существующей подписки
renewal_service = SubscriptionRenewalService()
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, best_period)
# Продление существующей подписки (reuse cached pricing from loop above)
if best_pricing is None:
raise ValueError('best_pricing is None despite best_period being set')
pricing = best_pricing
await renewal_service.finalize(
db,
@@ -1333,7 +1357,10 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
)
await callback.answer(
texts.t('ACTIVATION_SUCCESS', f'✅ Подписка продлена на {best_period} дней за {best_price // 100} ₽!'),
texts.t(
'ACTIVATION_SUCCESS',
f'✅ Подписка продлена на {best_period} дней за {pricing.final_total // 100} ₽!',
),
show_alert=True,
)
else:
+9 -1
View File
@@ -1,5 +1,6 @@
import structlog
from aiogram import Bot, Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import InaccessibleMessage
from sqlalchemy.ext.asyncio import AsyncSession
@@ -24,7 +25,14 @@ async def show_promocode_menu(callback: types.CallbackQuery, db_user: User, stat
if isinstance(callback.message, InaccessibleMessage):
await callback.message.answer(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language))
else:
await callback.message.edit_text(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language))
try:
await callback.message.edit_text(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language))
except TelegramBadRequest as error:
error_message = str(error).lower()
if 'there is no text in the message to edit' in error_message:
await callback.message.answer(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language))
else:
raise
await state.set_state(PromoCodeStates.waiting_for_code)
await callback.answer()
+50 -18
View File
@@ -1,3 +1,4 @@
import hashlib
import json
from pathlib import Path
@@ -37,10 +38,14 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
texts = get_texts(db_user.language)
if not db_user.referral_code:
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
return
summary = await get_user_referral_summary(db, db_user.id)
bot_username = (await callback.bot.get_me()).username
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
referral_text = (
texts.t('REFERRAL_PROGRAM_TITLE', '👥 <b>Реферальная программа</b>')
@@ -78,24 +83,40 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
).format(amount=texts.format_price(summary['month_earned_kopeks']))
+ '\n\n'
+ texts.t('REFERRAL_REWARDS_HEADER', '🎁 <b>Как работают награды:</b>')
+ '\n'
+ texts.t(
)
if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
referral_text += '\n' + texts.t(
'REFERRAL_REWARD_NEW_USER',
'• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>',
).format(
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS),
)
+ '\n'
+ texts.t(
if settings.REFERRAL_INVITER_BONUS_KOPEKS > 0:
referral_text += '\n' + texts.t(
'REFERRAL_REWARD_INVITER',
'• Вы получаете при первом пополнении реферала: <b>{bonus}</b>',
).format(bonus=texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS))
+ '\n'
+ texts.t(
if settings.REFERRAL_MAX_COMMISSION_PAYMENTS > 0:
commission_line = texts.t(
'REFERRAL_REWARD_COMMISSION_LIMITED',
'• Комиссия с первых {max_payments} пополнений реферала: <b>{percent}%</b>',
).format(
percent=get_effective_referral_commission_percent(db_user),
max_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
)
else:
commission_line = texts.t(
'REFERRAL_REWARD_COMMISSION',
'• Комиссия с каждого пополнения реферала: <b>{percent}%</b>',
).format(percent=get_effective_referral_commission_percent(db_user))
referral_text += (
'\n'
+ commission_line
+ '\n\n'
+ texts.t('REFERRAL_LINK_TITLE', '🔗 <b>Ваша реферальная ссылка:</b>')
+ f'\n<code>{referral_link}</code>\n\n'
@@ -213,17 +234,22 @@ async def show_referral_qr(
callback: types.CallbackQuery,
db_user: User,
):
await callback.answer()
texts = get_texts(db_user.language)
if not db_user.referral_code:
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
return
await callback.answer()
bot_username = (await callback.bot.get_me()).username
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
qr_dir = Path('data') / 'referral_qr'
qr_dir.mkdir(parents=True, exist_ok=True)
file_path = qr_dir / f'{db_user.id}.png'
link_hash = hashlib.md5(referral_link.encode()).hexdigest()[:8]
file_path = qr_dir / f'{db_user.id}_{link_hash}.png'
if not file_path.exists():
img = qrcode.make(referral_link)
img.save(file_path)
@@ -454,20 +480,26 @@ async def show_referral_analytics(callback: types.CallbackQuery, db_user: User,
async def create_invite_message(callback: types.CallbackQuery, db_user: User):
texts = get_texts(db_user.language)
bot_username = (await callback.bot.get_me()).username
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
if not db_user.referral_code:
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
return
invite_text = (
texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!')
+ '\n\n'
+ texts.t(
bot_username = (await callback.bot.get_me()).username
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
invite_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!')
if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
invite_text += '\n\n' + texts.t(
'REFERRAL_INVITE_BONUS',
'💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!',
).format(
minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS),
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
)
+ '\n\n'
invite_text += (
'\n\n'
+ texts.t('REFERRAL_INVITE_FEATURE_FAST', '🚀 Быстрое подключение')
+ '\n'
+ texts.t('REFERRAL_INVITE_FEATURE_SERVERS', '🌍 Серверы по всему миру')
+3 -2
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime
import structlog
@@ -169,7 +170,7 @@ def _split_into_pages(
pages.append((current_online, current_offline))
return pages if pages else [([], [])]
return pages or [([], [])]
def _format_server_lines(
@@ -189,7 +190,7 @@ def _format_server_lines(
else:
latency_text = texts.t('SERVER_STATUS_OFFLINE', 'нет ответа')
name = server.display_name or server.name
name = html.escape(server.display_name or server.name)
flag_prefix = f'{server.flag} ' if server.flag else ''
server_line = f'{flag_prefix}{name}{latency_text}'
lines.append(f'<blockquote>{server_line}</blockquote>')
+3 -1
View File
@@ -649,6 +649,7 @@ async def handle_simple_subscription_pay_with_balance(
subscription_params['period_days'],
False, # was_trial_conversion
amount_kopeks=price_kopeks,
purchase_type='renewal' if existing_subscription else 'first_purchase',
)
except Exception as e:
logger.error('Ошибка отправки уведомления админам о покупке', error=e)
@@ -970,7 +971,7 @@ async def handle_simple_subscription_payment_method(
from aiogram.types import BufferedInputFile
# Используем qr_confirmation_data если доступно, иначе confirmation_url
qr_data = qr_confirmation_data if qr_confirmation_data else confirmation_url
qr_data = qr_confirmation_data or confirmation_url
# Создаем QR-код из полученных данных
qr = qrcode.QRCode(version=1, box_size=10, border=5)
@@ -2368,6 +2369,7 @@ async def confirm_simple_subscription_purchase(
subscription_params['period_days'],
False, # was_trial_conversion
amount_kopeks=price_kopeks,
purchase_type='renewal' if existing_subscription else 'first_purchase',
)
except Exception as e:
logger.error('Ошибка отправки уведомления админам о покупке', error=e)
+97 -1
View File
@@ -285,6 +285,90 @@ async def _handle_trial_payment(
return False
_PURCHASE_TOKEN_RE = __import__('re').compile(r'^[A-Za-z0-9_\-]{10,100}$')
async def _handle_guest_purchase_payment(
message: types.Message,
db: AsyncSession,
user,
stars_amount: int,
payload: str,
telegram_payment_charge_id: str,
):
"""Обработка Stars платежа для гостевой покупки (подарочная подписка из кабинета)."""
from app.database.crud.landing import get_purchase_by_token
from app.services.payment.common import try_fulfill_guest_purchase
try:
purchase_token = payload[len('guest_purchase_') :]
if not purchase_token or not _PURCHASE_TOKEN_RE.match(purchase_token):
logger.error('Invalid purchase_token format in guest_purchase payload', payload=payload)
await message.answer('❌ Ошибка: неверный формат платежа.')
return
# Verify Stars amount matches expected price (±5% tolerance for conversion rounding)
existing = await get_purchase_by_token(db, purchase_token)
if existing and existing.amount_kopeks:
expected_stars = max(1, settings.rubles_to_stars(existing.amount_kopeks / 100))
tolerance = max(1, round(expected_stars * 0.05))
if abs(stars_amount - expected_stars) > tolerance:
logger.error(
'Stars amount mismatch for guest purchase',
paid_stars=stars_amount,
expected_stars=expected_stars,
purchase_token_prefix=purchase_token[:5],
)
await message.answer('❌ Сумма оплаты не совпадает с ожидаемой.')
return
# Calculate kopeks from stars
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
# Build metadata matching what other providers use
metadata = {
'purpose': 'guest_purchase',
'purchase_token': purchase_token,
}
result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=amount_kopeks,
provider_payment_id=telegram_payment_charge_id,
provider_name='telegram_stars',
skip_amount_check=True,
)
if result is True:
await message.answer(
'🎁 <b>Подарочная подписка успешно оплачена!</b>\n\n'
f'⭐ Потрачено: {stars_amount} Stars\n\n'
'Подарок будет доставлен получателю.',
parse_mode='HTML',
)
logger.info(
'✅ Guest purchase fulfilled via Stars',
user_id=user.id,
stars_amount=stars_amount,
purchase_token_prefix=purchase_token[:5],
)
elif result is False:
await message.answer(
'❌ Произошла ошибка при обработке подарочной подписки. Обратитесь в поддержку.',
)
else:
logger.error('try_fulfill_guest_purchase returned None for Stars gift', payload=payload)
await message.answer('❌ Ошибка обработки платежа. Обратитесь в поддержку.')
except Exception as e:
logger.error('Error handling guest purchase Stars payment', error=e, exc_info=True)
await message.answer(
'❌ Произошла ошибка при обработке подарочной подписки. Обратитесь в поддержку.',
)
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
texts = get_texts(DEFAULT_LANGUAGE)
@@ -296,7 +380,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
invoice_payload=query.invoice_payload,
)
allowed_prefixes = ('balance_', 'admin_stars_test_', 'simple_sub_', 'wheel_spin_', 'trial_')
allowed_prefixes = ('balance_', 'admin_stars_test_', 'simple_sub_', 'wheel_spin_', 'trial_', 'guest_purchase_')
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
logger.warning('Невалидный payload', invoice_payload=query.invoice_payload)
@@ -402,6 +486,18 @@ async def handle_successful_payment(message: types.Message, db: AsyncSession, st
)
return
# Обработка оплаты гостевой покупки (подарочная подписка из кабинета)
if payment.invoice_payload and payment.invoice_payload.startswith('guest_purchase_'):
await _handle_guest_purchase_payment(
message=message,
db=db,
user=user,
stars_amount=payment.total_amount,
payload=payment.invoice_payload,
telegram_payment_charge_id=payment.telegram_payment_charge_id,
)
return
payment_service = PaymentService(message.bot)
state_data = await state.get_data()
+370 -65
View File
@@ -1,10 +1,14 @@
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
import structlog
from aiogram import Bot, Dispatcher, F, types
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -15,11 +19,12 @@ from app.database.crud.campaign import (
from app.database.crud.subscription import decrement_subscription_server_counts
from app.database.crud.user import (
create_user,
find_phantom_user_by_username,
get_user_by_referral_code,
get_user_by_telegram_id,
)
from app.database.crud.user_message import get_random_active_message
from app.database.models import PinnedMessage, SubscriptionStatus, UserStatus
from app.database.models import GuestPurchase, GuestPurchaseStatus, PinnedMessage, SubscriptionStatus, UserStatus
from app.keyboards.inline import (
get_back_keyboard,
get_language_selection_keyboard,
@@ -58,6 +63,140 @@ from app.utils.user_utils import generate_unique_referral_code
logger = structlog.get_logger(__name__)
async def _activate_pending_gift_after_registration(
db: AsyncSession,
state: FSMContext,
user: 'User',
answer_func: Callable[..., Any],
) -> None:
"""Extract pending_gift_token from FSM state and activate it for the newly registered user.
Must be called BEFORE state.clear() to preserve the token.
"""
gift_token: str | None = None
try:
fresh_state = await state.get_data()
gift_token = fresh_state.get('pending_gift_token')
if not gift_token:
return
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.services.guest_purchase_service import activate_purchase as svc_activate
# Support both full token and prefix-based lookup (Telegram truncates long start params)
if len(gift_token) >= 64:
token_filter = GuestPurchase.token == gift_token
else:
token_filter = GuestPurchase.token.startswith(gift_token)
gift_result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff))
.where(token_filter, GuestPurchase.is_gift.is_(True))
.with_for_update()
)
gift_purchase = gift_result.scalars().first()
if (
gift_purchase
and gift_purchase.is_gift
and gift_purchase.status
in (
GuestPurchaseStatus.PENDING_ACTIVATION.value,
GuestPurchaseStatus.PAID.value,
)
and (gift_purchase.user_id is None or gift_purchase.user_id == user.id)
and gift_purchase.buyer_user_id != user.id # prevent self-activation
):
if gift_purchase.user_id is None:
gift_purchase.user_id = user.id
# Transition PAID → PENDING_ACTIVATION so activate_purchase() accepts it
if gift_purchase.status == GuestPurchaseStatus.PAID.value:
gift_purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
await db.flush()
await svc_activate(db, gift_purchase.token, skip_notification=True)
tariff_name = gift_purchase.tariff.name if gift_purchase.tariff else ''
await answer_func(
f'🎁 <b>Подарок активирован!</b>\n'
f'{tariff_name}{gift_purchase.period_days} дн.\n\n'
f'Ваша подписка обновлена.',
parse_mode=ParseMode.HTML,
)
except Exception:
logger.exception(
'Failed to auto-activate gift after registration',
token_prefix=(gift_token or '')[:5],
)
async def _claim_phantom_user(
db: AsyncSession,
phantom: 'User',
*,
telegram_id: int,
username: str | None,
first_name: str | None,
last_name: str | None,
language: str,
referrer_id: int | None,
) -> tuple[bool, 'User | None']:
"""Claim a phantom user by backfilling Telegram profile data.
Returns (success, user). On IntegrityError falls back to existing user lookup.
Note: Phantom users created when Bot.get_chat() fails at purchase time are matched
by username only. Since Telegram usernames are changeable and reassignable, this is
inherently vulnerable to username change attacks. When Bot.get_chat() succeeds at
purchase time, telegram_id is stored on the user and the phantom path is not used.
"""
from app.utils.validators import sanitize_telegram_name
phantom.telegram_id = telegram_id
phantom.username = username
phantom.first_name = sanitize_telegram_name(first_name)
phantom.last_name = sanitize_telegram_name(last_name)
phantom.language = language
phantom.status = UserStatus.ACTIVE.value
if referrer_id and referrer_id != phantom.id:
phantom.referred_by_id = referrer_id
if not phantom.referral_code:
phantom.referral_code = await generate_unique_referral_code(db, telegram_id)
phantom.updated_at = datetime.now(UTC)
phantom.last_activity = datetime.now(UTC)
try:
await db.commit()
except IntegrityError:
await db.rollback()
logger.warning(
'IntegrityError claiming phantom user, falling back to existing user lookup',
phantom_user_id=phantom.id,
telegram_id=telegram_id,
)
existing = await get_user_by_telegram_id(db, telegram_id)
return False, existing
await db.refresh(phantom, ['subscription'])
logger.info(
'Claimed phantom user from guest purchase',
phantom_user_id=phantom.id,
telegram_id=telegram_id,
)
# Sync Remnawave panel with updated user data (telegram_id, username, etc.)
if phantom.subscription:
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, phantom.subscription)
except Exception as exc:
logger.warning(
'Failed to update Remnawave panel after phantom claim',
phantom_user_id=phantom.id,
error=str(exc),
)
return True, phantom
def _calculate_subscription_flags(subscription):
if not subscription:
return False, False
@@ -377,6 +516,20 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
if state_needs_update:
await state.set_data(data)
# Handle gift code deep links: /start GIFT_{token}
if start_parameter and start_parameter.startswith('GIFT_'):
gift_token = start_parameter[5:] # Strip "GIFT_" prefix
if len(gift_token) >= 8:
logger.info(
'Gift code deep link detected',
token_prefix=gift_token[:5],
telegram_id=message.from_user.id,
)
# For new users, gift is auto-activated via
# _activate_pending_gift_after_registration() before state.clear().
await state.update_data(pending_gift_token=gift_token)
start_parameter = None # Don't treat as campaign or referral
if start_parameter:
campaign = await get_campaign_by_start_parameter(
db,
@@ -412,7 +565,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
if referral_code:
await state.update_data(referral_code=referral_code)
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
user = db_user or await get_user_by_telegram_id(db, message.from_user.id)
if campaign and not campaign_notification_sent:
try:
@@ -484,6 +637,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
except Exception as e:
logger.error('Ошибка отправки уведомления о рекламной кампании', error=e)
# Auto-activate pending gift if deep link contained GIFT_
if user:
await _activate_pending_gift_after_registration(db, state, user, message.answer)
await state.update_data(pending_gift_token=None)
# Refresh user to pick up newly created subscription
await db.refresh(user, attribute_names=['subscription'])
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
pinned_message = await get_active_pinned_message(db)
@@ -594,6 +754,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
await db.execute(delete(Transaction).where(Transaction.user_id == user.id))
if user.balance_kopeks > 0:
logger.warning(
'⚠️ DELETED-восстановление: обнуляем ненулевой баланс',
telegram_id=user.telegram_id,
balance_kopeks=user.balance_kopeks,
)
user.status = UserStatus.ACTIVE.value
user.balance_kopeks = 0
user.remnawave_uuid = None
@@ -1136,6 +1303,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
subscription_is_active=subscription_is_active,
)
pinned_message = await get_active_pinned_message(db)
try:
keyboard = await get_main_menu_keyboard_async(
db=db,
@@ -1150,8 +1318,11 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(callback.bot, db, existing_user, pinned_message)
await callback.message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
await _send_pinned_message(callback.bot, db, existing_user)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(callback.bot, db, existing_user, pinned_message)
except Exception as e:
logger.error('Ошибка при показе главного меню существующему пользователю', error=e)
await callback.message.answer(
@@ -1180,6 +1351,13 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
# Prevent self-referral when partner re-registers via own campaign link
safe_referrer_id = referrer_id if referrer_id != existing_user.id else None
if existing_user.balance_kopeks > 0:
logger.warning(
'⚠️ DELETED-восстановление: обнуляем ненулевой баланс',
telegram_id=existing_user.telegram_id,
balance_kopeks=existing_user.balance_kopeks,
)
existing_user.username = callback.from_user.username
existing_user.first_name = callback.from_user.first_name
existing_user.last_name = callback.from_user.last_name
@@ -1199,21 +1377,50 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
logger.info('✅ Пользователь восстановлен', from_user_id=callback.from_user.id)
elif not existing_user:
logger.info('🆕 Создаем нового пользователя', from_user_id=callback.from_user.id)
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
user = await create_user(
db=db,
telegram_id=callback.from_user.id,
username=callback.from_user.username,
first_name=callback.from_user.first_name,
last_name=callback.from_user.last_name,
language=language,
referred_by_id=referrer_id,
referral_code=referral_code,
# Check for phantom user created by guest purchase (gift by @username)
phantom = (
await find_phantom_user_by_username(db, callback.from_user.username)
if callback.from_user.username
else None
)
await db.refresh(user, ['subscription'])
if phantom:
claimed, user = await _claim_phantom_user(
db,
phantom,
telegram_id=callback.from_user.id,
username=callback.from_user.username,
first_name=callback.from_user.first_name,
last_name=callback.from_user.last_name,
language=language,
referrer_id=referrer_id,
)
if not claimed and user:
# IntegrityError fallback — use existing user
await db.refresh(user, ['subscription'])
elif not claimed:
logger.critical(
'Phantom claim failed with no fallback user, proceeding to normal registration',
telegram_id=callback.from_user.id,
phantom_user_id=phantom.id,
)
phantom = None
if not phantom:
logger.info('🆕 Создаем нового пользователя', from_user_id=callback.from_user.id)
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
user = await create_user(
db=db,
telegram_id=callback.from_user.id,
username=callback.from_user.username,
first_name=callback.from_user.first_name,
last_name=callback.from_user.last_name,
language=language,
referred_by_id=referrer_id,
referral_code=referral_code,
)
await db.refresh(user, ['subscription'])
else:
logger.info('🔄 Обновляем существующего пользователя', from_user_id=callback.from_user.id)
existing_user.status = UserStatus.ACTIVE.value
@@ -1262,6 +1469,9 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
telegram_id=user.telegram_id,
)
# Auto-activate pending gift for newly registered user (before state.clear() wipes the token)
await _activate_pending_gift_after_registration(db, state, user, callback.message.answer)
await state.clear()
if campaign_message:
@@ -1273,15 +1483,19 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
from app.database.crud.welcome_text import get_welcome_text_for_user
offer_text = await get_welcome_text_for_user(db, callback.from_user)
pinned_message = await get_active_pinned_message(db)
if offer_text:
try:
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(callback.bot, db, user, pinned_message)
await callback.message.answer(
offer_text,
reply_markup=get_post_registration_keyboard(user.language),
)
logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id)
await _send_pinned_message(callback.bot, db, user)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(callback.bot, db, user, pinned_message)
except TelegramBadRequest as e:
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
logger.warning('HTML parse error в приветственном сообщении, повтор без parse_mode', error=e)
@@ -1291,7 +1505,8 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
reply_markup=get_post_registration_keyboard(user.language),
parse_mode=None,
)
await _send_pinned_message(callback.bot, db, user)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(callback.bot, db, user, pinned_message)
except Exception as fallback_err:
logger.error('Ошибка при повторной отправке приветственного сообщения', fallback_err=fallback_err)
else:
@@ -1336,8 +1551,11 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(callback.bot, db, user, pinned_message)
await callback.message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
await _send_pinned_message(callback.bot, db, user)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(callback.bot, db, user, pinned_message)
logger.info('✅ Главное меню показано пользователю', telegram_id=user.telegram_id)
except Exception as e:
logger.error('Ошибка при показе главного меню', error=e)
@@ -1387,6 +1605,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
subscription_is_active=subscription_is_active,
)
pinned_message = await get_active_pinned_message(db)
try:
keyboard = await get_main_menu_keyboard_async(
db=db,
@@ -1401,8 +1620,11 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, existing_user, pinned_message)
await message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
await _send_pinned_message(message.bot, db, existing_user)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, existing_user, pinned_message)
except Exception as e:
logger.error('Ошибка при показе главного меню существующему пользователю', error=e)
await message.answer(
@@ -1431,6 +1653,13 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
# Prevent self-referral when partner re-registers via own campaign link
safe_referrer_id = referrer_id if referrer_id != existing_user.id else None
if existing_user.balance_kopeks > 0:
logger.warning(
'⚠️ DELETED-восстановление: обнуляем ненулевой баланс',
telegram_id=existing_user.telegram_id,
balance_kopeks=existing_user.balance_kopeks,
)
existing_user.username = message.from_user.username
existing_user.first_name = message.from_user.first_name
existing_user.last_name = message.from_user.last_name
@@ -1450,21 +1679,47 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
logger.info('✅ Пользователь восстановлен', from_user_id=message.from_user.id)
elif not existing_user:
logger.info('🆕 Создаем нового пользователя', from_user_id=message.from_user.id)
referral_code = await generate_unique_referral_code(db, message.from_user.id)
user = await create_user(
db=db,
telegram_id=message.from_user.id,
username=message.from_user.username,
first_name=message.from_user.first_name,
last_name=message.from_user.last_name,
language=language,
referred_by_id=referrer_id,
referral_code=referral_code,
# Check for phantom user created by guest purchase (gift by @username)
phantom = (
await find_phantom_user_by_username(db, message.from_user.username) if message.from_user.username else None
)
await db.refresh(user, ['subscription'])
if phantom:
claimed, user = await _claim_phantom_user(
db,
phantom,
telegram_id=message.from_user.id,
username=message.from_user.username,
first_name=message.from_user.first_name,
last_name=message.from_user.last_name,
language=language,
referrer_id=referrer_id,
)
if not claimed and user:
await db.refresh(user, ['subscription'])
elif not claimed:
logger.critical(
'Phantom claim failed with no fallback user, proceeding to normal registration',
telegram_id=message.from_user.id,
phantom_user_id=phantom.id,
)
phantom = None
if not phantom:
logger.info('🆕 Создаем нового пользователя', from_user_id=message.from_user.id)
referral_code = await generate_unique_referral_code(db, message.from_user.id)
user = await create_user(
db=db,
telegram_id=message.from_user.id,
username=message.from_user.username,
first_name=message.from_user.first_name,
last_name=message.from_user.last_name,
language=language,
referred_by_id=referrer_id,
referral_code=referral_code,
)
await db.refresh(user, ['subscription'])
else:
logger.info('🔄 Обновляем существующего пользователя', from_user_id=message.from_user.id)
existing_user.status = UserStatus.ACTIVE.value
@@ -1542,6 +1797,9 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
'🗑️ COMPLETE: Redis payload удален после успешной регистрации пользователя', telegram_id=user.telegram_id
)
# Auto-activate pending gift for newly registered user (before state.clear() wipes the token)
await _activate_pending_gift_after_registration(db, state, user, message.answer)
await state.clear()
if campaign_message:
@@ -1553,6 +1811,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
from app.database.crud.welcome_text import get_welcome_text_for_user
offer_text = await get_welcome_text_for_user(db, message.from_user)
pinned_message = await get_active_pinned_message(db)
if offer_text:
try:
@@ -1563,12 +1822,15 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
else:
keyboard = get_post_registration_keyboard(user.language)
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, user, pinned_message)
await message.answer(
offer_text,
reply_markup=keyboard,
)
logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id)
await _send_pinned_message(message.bot, db, user)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, user, pinned_message)
except TelegramBadRequest as e:
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
logger.warning('HTML parse error в приветственном сообщении, повтор без parse_mode', error=e)
@@ -1578,7 +1840,8 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
reply_markup=keyboard,
parse_mode=None,
)
await _send_pinned_message(message.bot, db, user)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, user, pinned_message)
except Exception as fallback_err:
logger.error('Ошибка при повторной отправке приветственного сообщения', fallback_err=fallback_err)
else:
@@ -1623,9 +1886,12 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, user, pinned_message)
await message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
logger.info('✅ Главное меню показано пользователю', telegram_id=user.telegram_id)
await _send_pinned_message(message.bot, db, user)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, user, pinned_message)
except Exception as e:
logger.error('Ошибка при показе главного меню', error=e)
await message.answer(
@@ -1652,6 +1918,9 @@ def _get_subscription_status(user, texts):
if actual_status == 'disabled':
return texts.t('SUB_STATUS_DISABLED', '⚫ Отключена')
if actual_status == 'limited':
return texts.t('SUB_STATUS_LIMITED', '⚠️ Трафик исчерпан')
if actual_status == 'pending':
return texts.t('SUB_STATUS_PENDING', '⏳ Ожидает активации')
@@ -1810,7 +2079,7 @@ async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
async def required_sub_channel_check(
query: types.CallbackQuery, bot: Bot, state: FSMContext, db: AsyncSession, db_user=None
):
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
from app.utils.message_patch import _cache_logo_file_id, caption_exceeds_telegram_limit, get_logo_media
language = DEFAULT_LANGUAGE
texts = get_texts(language)
@@ -1873,14 +2142,6 @@ async def required_sub_channel_check(
# Очищаем Redis после успешной проверки подписки
await delete_pending_payload_from_redis(query.from_user.id)
# Всегда обновляем referral_code если есть новый payload
# (исправление бага с устаревшими данными в state)
campaign = await get_campaign_by_start_parameter(
db,
pending_start_payload,
only_active=True,
)
# Обрабатываем payload только если ещё не обработан
# (проверяем по наличию referral_code или campaign_id в state)
if not state_data.get('referral_code') and not state_data.get('campaign_id'):
@@ -1892,7 +2153,13 @@ async def required_sub_channel_check(
if campaign:
state_data['campaign_id'] = campaign.id
logger.info('📣 CHANNEL CHECK: Кампания восстановлена из payload', campaign_id=campaign.id)
if campaign.partner_user_id:
state_data['referrer_id'] = campaign.partner_user_id
logger.info(
'📣 CHANNEL CHECK: Кампания восстановлена из payload',
campaign_id=campaign.id,
partner_user_id=campaign.partner_user_id,
)
else:
state_data['referral_code'] = pending_start_payload
logger.info(
@@ -1977,7 +2244,11 @@ async def required_sub_channel_check(
custom_buttons=custom_buttons,
)
if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900:
pinned_message = await get_active_pinned_message(db)
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(bot, db, user, pinned_message)
if settings.ENABLE_LOGO_MODE and not caption_exceeds_telegram_limit(menu_text):
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=get_logo_media(),
@@ -1993,7 +2264,8 @@ async def required_sub_channel_check(
reply_markup=keyboard,
parse_mode='HTML',
)
await _send_pinned_message(bot, db, user)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(bot, db, user, pinned_message)
else:
from app.keyboards.inline import get_rules_keyboard
@@ -2014,19 +2286,47 @@ async def required_sub_channel_check(
referrer_id = referrer.id
logger.info('✅ CHANNEL CHECK: Реферер найден из ссылки', referrer_id=referrer.id)
referral_code = await generate_unique_referral_code(db, query.from_user.id)
user = await create_user(
db=db,
telegram_id=query.from_user.id,
username=query.from_user.username,
first_name=query.from_user.first_name,
last_name=query.from_user.last_name,
language=language,
referral_code=referral_code,
referred_by_id=referrer_id,
# Check for phantom user created by guest purchase (gift by @username)
phantom = (
await find_phantom_user_by_username(db, query.from_user.username)
if query.from_user.username
else None
)
await db.refresh(user, ['subscription'])
if phantom:
claimed, user = await _claim_phantom_user(
db,
phantom,
telegram_id=query.from_user.id,
username=query.from_user.username,
first_name=query.from_user.first_name,
last_name=query.from_user.last_name,
language=language,
referrer_id=referrer_id,
)
if not claimed and user:
await db.refresh(user, ['subscription'])
elif not claimed:
logger.critical(
'Phantom claim failed with no fallback user, proceeding to normal registration',
telegram_id=query.from_user.id,
phantom_user_id=phantom.id,
)
phantom = None
if not phantom:
referral_code = await generate_unique_referral_code(db, query.from_user.id)
user = await create_user(
db=db,
telegram_id=query.from_user.id,
username=query.from_user.username,
first_name=query.from_user.first_name,
last_name=query.from_user.last_name,
language=language,
referral_code=referral_code,
referred_by_id=referrer_id,
)
await db.refresh(user, ['subscription'])
# ИСПРАВЛЕНИЕ БАГА: Очищаем pending_start_payload из state после создания пользователя
state_data.pop('pending_start_payload', None)
@@ -2070,7 +2370,11 @@ async def required_sub_channel_check(
custom_buttons=custom_buttons,
)
if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900:
pinned_message = await get_active_pinned_message(db)
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(bot, db, user, pinned_message)
if settings.ENABLE_LOGO_MODE and not caption_exceeds_telegram_limit(menu_text):
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=get_logo_media(),
@@ -2086,7 +2390,8 @@ async def required_sub_channel_check(
reply_markup=keyboard,
parse_mode='HTML',
)
await _send_pinned_message(bot, db, user)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(bot, db, user, pinned_message)
else:
await bot.send_message(
chat_id=query.from_user.id,
@@ -2100,7 +2405,7 @@ async def required_sub_channel_check(
else:
rules_text = await get_rules(language)
if settings.ENABLE_LOGO_MODE and len(rules_text) <= 900:
if settings.ENABLE_LOGO_MODE and not caption_exceeds_telegram_limit(rules_text):
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=get_logo_media(),
+6
View File
@@ -2,8 +2,11 @@
from .autopay import (
handle_autopay_menu,
handle_confirm_unlink,
handle_saved_cards_list,
handle_subscription_cancel,
handle_subscription_config_back,
handle_unlink_card,
set_autopay_days,
show_autopay_days,
toggle_autopay,
@@ -157,6 +160,7 @@ __all__ = [
'handle_app_selection',
'handle_autopay_menu',
'handle_change_devices',
'handle_confirm_unlink',
'handle_connect_subscription',
'handle_device_guide',
'handle_device_management',
@@ -172,12 +176,14 @@ __all__ = [
'handle_promo_offer_close',
'handle_reset_devices',
'handle_reset_traffic',
'handle_saved_cards_list',
'handle_single_device_reset',
'handle_specific_app_guide',
'handle_subscription_cancel',
'handle_subscription_config_back',
'handle_subscription_settings',
'handle_switch_traffic',
'handle_unlink_card',
'invalidate_app_config_cache',
'load_app_config_async',
'normalize_app',

Some files were not shown because too many files have changed in this diff Show More