Compare commits

...

69 Commits

Author SHA1 Message Date
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
88 changed files with 4501 additions and 3198 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.30.0"
".": "3.32.0"
}
+78
View File
@@ -1,5 +1,83 @@
# Changelog
## [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)
+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 тесты
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.30.0" # x-release-please-version
ARG VERSION="v3.32.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+244 -2
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,7 +20,7 @@ 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 (
@@ -26,6 +29,7 @@ from ..schemas.tariffs import (
PromoGroupInfo,
ServerInfo,
ServerTrafficLimit,
SyncSquadsResponse,
TariffCreateRequest,
TariffDetailResponse,
TariffListItem,
@@ -127,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,
@@ -265,6 +270,8 @@ async def get_tariff(
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,
)
@@ -321,6 +328,8 @@ async def create_new_tariff(
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)
@@ -347,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:
@@ -413,6 +426,9 @@ async def update_existing_tariff(
# Внешний сквад (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)
@@ -426,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)
@@ -586,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],
)
+57 -8
View File
@@ -4,10 +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,
@@ -25,6 +26,7 @@ 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,
@@ -36,6 +38,7 @@ from app.database.models import (
Transaction,
TransactionType,
User,
UserPromoGroup,
UserStatus,
)
from app.utils.timezone import panel_datetime_to_utc
@@ -1119,9 +1122,19 @@ 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
@@ -1140,8 +1153,6 @@ 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
@@ -1268,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)
@@ -1276,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(
@@ -1629,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)
@@ -2484,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(),
+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)
+13 -4
View File
@@ -82,9 +82,11 @@ async def get_gift_config(
balance_kopeks=user.balance_kopeks,
)
# Load active tariffs
# Load active tariffs visible in gift section
result = await db.execute(
select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.display_order, Tariff.id)
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()
@@ -241,7 +243,7 @@ async def create_gift_purchase(
# Find tariff and validate period
tariff = await get_tariff_by_id(db, body.tariff_id)
if tariff is None or not tariff.is_active:
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',
@@ -380,7 +382,14 @@ async def create_gift_purchase(
from app.services.payment_service import PaymentService
payment_service = 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,
+7
View File
@@ -607,6 +607,13 @@ async def create_landing_purchase(
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')
+1 -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 '',
+109 -215
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
@@ -141,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
@@ -235,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,
@@ -324,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,
)
@@ -424,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:
@@ -539,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={
@@ -560,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,
}
@@ -934,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)
@@ -944,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:
@@ -4172,6 +4031,15 @@ 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)
)
user.subscription = locked_result.scalar_one()
# Use actual_status for correct status check (handles time-based expiration)
actual_status = user.subscription.actual_status
if actual_status == 'expired':
@@ -4334,6 +4202,7 @@ async def switch_tariff(
upgrade_cost,
description,
mark_as_paid_subscription=True,
commit=False,
)
if not success:
raise HTTPException(
@@ -4341,7 +4210,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,
@@ -4349,6 +4218,7 @@ async def switch_tariff(
amount_kopeks=upgrade_cost,
description=description,
payment_method=PaymentMethod.BALANCE,
commit=False,
)
else:
# Free switch (downgrade) — record in history
@@ -4359,13 +4229,23 @@ async def switch_tariff(
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=0,
description=description,
commit=False,
)
# Update subscription
old_tariff_name = current_tariff.name if current_tariff else 'Unknown'
# Preserve extra purchased devices above the old tariff's base limit
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
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.device_limit = calc_device_limit_on_tariff_switch(
current_device_limit=user.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,
)
user.subscription.connected_squads = new_tariff.allowed_squads or []
# Reset purchased traffic and delete TrafficPurchase records on tariff switch
@@ -4392,6 +4272,19 @@ async def switch_tariff(
user.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
try:
@@ -4513,6 +4406,7 @@ async def toggle_subscription_pause(
was_disabled = user.subscription.status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
)
# System-DISABLED subs (insufficient balance) should always be treated as needing resume,
+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
+19
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
@@ -114,6 +115,8 @@ class TariffDetailResponse(BaseModel):
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
@@ -170,6 +173,8 @@ class TariffCreateRequest(BaseModel):
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):
@@ -209,6 +214,8 @@ class TariffUpdateRequest(BaseModel):
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):
@@ -243,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)
+1
View File
@@ -22,6 +22,7 @@ class SubscriptionStatusEnum(StrEnum):
ACTIVE = 'active'
EXPIRED = 'expired'
DISABLED = 'disabled'
LIMITED = 'limited'
PENDING = 'pending'
+39
View File
@@ -1415,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
@@ -2680,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}
+11 -3
View File
@@ -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
+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
+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()
+59 -263
View File
@@ -18,7 +18,6 @@ from app.database.models import (
Transaction,
TransactionType,
User,
UserPromoGroup,
UserStatus,
)
from app.utils.pricing_utils import calculate_months_from_days
@@ -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:
@@ -357,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:
"""Продлевает подписку на указанное количество дней.
@@ -389,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:
@@ -445,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
@@ -567,9 +594,13 @@ async def extend_subscription(
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription, ['tariff'])
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)
@@ -772,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
@@ -1367,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
@@ -1416,232 +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 = []
now = datetime.now(UTC)
days_to_pay = max(1, (subscription.end_date - now).days)
period_hint_days = days_to_pay
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 = int(discounted_traffic_per_month * days_to_pay / 30)
total_cost += traffic_total_cost
message = f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес × {days_to_pay} дн. = {traffic_total_cost / 100}'
if traffic_discount_per_month > 0:
message += (
f' (скидка {traffic_discount_percent}%: -{int(traffic_discount_per_month * days_to_pay / 30) / 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 = int(discounted_devices_per_month * days_to_pay / 30)
total_cost += devices_total_cost
message = f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес × {days_to_pay} дн. = {devices_total_cost / 100}'
if devices_discount_per_month > 0:
message += (
f' (скидка {devices_discount_percent}%: -{int(devices_discount_per_month * days_to_pay / 30) / 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 = int(discounted_server_per_month * days_to_pay / 30)
total_cost += server_total_cost
message = f'Сервер {server_name}: {server_price_per_month / 100}₽/мес × {days_to_pay} дн. = {server_total_cost / 100}'
if server_discount_per_month > 0:
message += f' (скидка {servers_discount_percent}%: -{int(server_discount_per_month * days_to_pay / 30) / 100}₽)'
logger.info(message)
logger.info('💰 Итого доплата за дн.: ₽', days_to_pay=days_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)
@@ -2206,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
# Обновляем время последнего списания для корректного расчёта следующего
+9
View File
@@ -185,6 +185,8 @@ 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
@@ -223,6 +225,8 @@ 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,
# Внешний сквад
@@ -290,6 +294,8 @@ 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
@@ -354,6 +360,9 @@ 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
+49 -19
View File
@@ -410,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,
@@ -421,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)
@@ -501,13 +519,19 @@ async def subtract_user_balance(
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(
@@ -572,8 +596,6 @@ 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,
@@ -581,11 +603,15 @@ async def subtract_user_balance(
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:
@@ -598,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:
+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(
+15 -2
View File
@@ -123,6 +123,7 @@ class SubscriptionStatus(Enum):
ACTIVE = 'active'
EXPIRED = 'expired'
DISABLED = 'disabled'
LIMITED = 'limited'
PENDING = 'pending'
@@ -974,6 +975,9 @@ 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 = использовать глобальную настройку
@@ -1144,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')
@@ -1345,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'
@@ -1369,6 +1378,8 @@ class Subscription(Base):
return '🟢 Активна'
if actual_status == 'disabled':
return '⚫ Отключена'
if actual_status == 'limited':
return '⚠️ Трафик исчерпан'
if actual_status == 'trial':
return '🎯 Тестовая'
@@ -1386,6 +1397,8 @@ class Subscription(Base):
return '💎'
if actual_status == 'disabled':
return ''
if actual_status == 'limited':
return '⚠️'
if actual_status == 'trial':
return '🎁'
@@ -1434,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):
+6 -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)
+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)
+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:
+3
View File
@@ -629,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()
+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'
'Должна быть выбрана минимум одна промогруппа.'
)
+21 -50
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 = ' Пакеты не настроены'
+37 -14
View File
@@ -1,3 +1,4 @@
import html
import re
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
@@ -868,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:
@@ -1209,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:
@@ -2711,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'
@@ -4002,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
@@ -4164,8 +4173,7 @@ async def _calculate_subscription_period_price(
subscription_service: SubscriptionService | None = None,
) -> int:
"""Рассчитывает стоимость подписки для администратора с учётом всех параметров."""
service = subscription_service or SubscriptionService()
from app.services.pricing_engine import pricing_engine
# Загружаем тариф для корректного расчёта в тарифном режиме
if subscription.tariff_id:
@@ -4174,13 +4182,13 @@ async def _calculate_subscription_period_price(
except Exception as e:
logger.warning('Не удалось загрузить тариф для расчёта цены', error=e)
return await service.calculate_renewal_price(
subscription=subscription,
period_days=period_days,
db=db,
pricing = await pricing_engine.calculate_renewal_price(
db,
subscription,
period_days,
user=target_user,
promo_group=getattr(target_user, 'promo_group', None),
)
return pricing.final_total
@admin_required
@@ -5309,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)
@@ -5364,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(
+6 -136
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,
@@ -404,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)
@@ -430,139 +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
# В режиме тарифов берём цену из тарифа пользователя
tariff = None
tariff_price_found = False
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)
if base_price_original > 0:
tariff_price_found = True
# Если не нашли в тарифе - используем PERIOD_PRICES
if base_price_original <= 0:
base_price_original = PERIOD_PRICES.get(duration_days, 0)
if tariff_price_found:
# Тарифный режим: серверы и трафик включены в цену тарифа.
# Порядок: база + устройства → скидка на полную сумму (как в calculate_renewal_price).
from app.utils.promo_offer import get_user_active_promo_discount_percent
original_price = base_price_original
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
device_limit = (
subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
)
extra_devices = max(0, device_limit - tariff_device_limit)
device_price_per_unit = (
tariff.device_price_kopeks
if tariff and tariff.device_price_kopeks is not None
else settings.PRICE_PER_DEVICE
)
months_in_period = calculate_months_from_days(duration_days)
devices_price = extra_devices * device_price_per_unit * months_in_period
original_price += devices_price
# Скидка промогруппы на полную сумму (база + устройства)
period_discount_percent = db_user.get_promo_discount('period', duration_days)
discount_total = original_price * period_discount_percent // 100
total_price = original_price - discount_total
# Promo-offer скидка (временная)
promo_offer_percent = get_user_active_promo_discount_percent(db_user)
if promo_offer_percent > 0:
promo_offer_discount = total_price * promo_offer_percent // 100
total_price = total_price - promo_offer_discount
else:
# Классический режим: серверы + трафик + устройства считаются отдельно
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)
@@ -1014,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_'))
+18 -10
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,16 +1297,18 @@ 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()
# Для продления используем PricingEngine (единый расчёт для всех поверхностей).
from app.services.pricing_engine import pricing_engine
# Для продления используем тот же сервис, что и при реальном списании,
# чтобы сумма проверки совпадала с суммой списания.
renewal_service = SubscriptionRenewalService() if subscription else None
try:
for period in available_periods:
if subscription and renewal_service:
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, period)
price = pricing.final_total
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
@@ -1311,14 +1316,15 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
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 and renewal_service:
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, min_period)
min_price = pricing.final_total
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
@@ -1336,8 +1342,10 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
try:
if subscription:
# Продление существующей подписки
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,
+20 -6
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>')
@@ -229,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)
@@ -470,8 +480,12 @@ 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)
if not db_user.referral_code:
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
return
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)
invite_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!')
+2 -1
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime
import structlog
@@ -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>')
+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()
+24
View File
@@ -754,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
@@ -1344,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
@@ -1639,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
@@ -1897,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', '⏳ Ожидает активации')
+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',
+90
View File
@@ -3,13 +3,20 @@ from aiogram.fsm.context import FSMContext
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.subscription import update_subscription_autopay
from app.database.models import User
from app.keyboards.inline import (
_get_payment_method_display_name,
get_autopay_days_keyboard,
get_autopay_keyboard,
get_confirm_unlink_keyboard,
get_countries_keyboard,
get_devices_keyboard,
get_saved_cards_keyboard,
get_subscription_period_keyboard,
get_traffic_packages_keyboard,
)
@@ -134,6 +141,89 @@ async def set_autopay_days(callback: types.CallbackQuery, db_user: User, db: Asy
await handle_autopay_menu(callback, db_user, db)
async def handle_saved_cards_list(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
texts = get_texts(db_user.language)
cards = await get_active_payment_methods_by_user(db, db_user.id)
if not cards:
await callback.message.edit_text(
texts.t(
'SAVED_CARDS_EMPTY',
'💳 <b>Привязанные карты</b>\n\nНет привязанных карт.\n'
'Карта привяжется автоматически при следующем пополнении баланса.',
),
reply_markup=get_saved_cards_keyboard([], db_user.language),
parse_mode='HTML',
)
else:
await callback.message.edit_text(
texts.t(
'SAVED_CARDS_TITLE',
'💳 <b>Привязанные карты</b>\n\nВыберите карту для отвязки:',
),
reply_markup=get_saved_cards_keyboard(cards, db_user.language),
parse_mode='HTML',
)
await callback.answer()
async def handle_unlink_card(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
texts = get_texts(db_user.language)
card_id = int(callback.data.split('_')[-1])
cards = await get_active_payment_methods_by_user(db, db_user.id)
card = next((c for c in cards if c.id == card_id), None)
if not card:
await callback.answer(
texts.t('SAVED_CARDS_UNLINK_ERROR', '❌ Не удалось отвязать карту'),
show_alert=True,
)
return
card_label = _get_payment_method_display_name(card, db_user.language)
text = texts.t(
'SAVED_CARDS_CONFIRM_UNLINK',
'Вы уверены, что хотите отвязать карту <b>{card}</b>?\n\n'
'После отвязки автоплатеж не сможет использовать эту карту.',
).format(card=card_label)
if len(cards) == 1:
text += texts.t(
'SAVED_CARDS_LAST_CARD_WARNING',
'\n\n⚠️ <b>Внимание:</b> это ваша последняя привязанная карта. '
'После отвязки автоплатеж не сможет списывать средства.',
)
await callback.message.edit_text(
text,
reply_markup=get_confirm_unlink_keyboard(card_id, db_user.language),
parse_mode='HTML',
)
await callback.answer()
async def handle_confirm_unlink(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
texts = get_texts(db_user.language)
card_id = int(callback.data.split('_')[-1])
success = await deactivate_payment_method(db, card_id, db_user.id)
if success:
await callback.answer(
texts.t('SAVED_CARDS_UNLINKED', '✅ Карта отвязана'),
)
else:
await callback.answer(
texts.t('SAVED_CARDS_UNLINK_ERROR', '❌ Не удалось отвязать карту'),
show_alert=True,
)
return
# Return to the updated cards list
await handle_saved_cards_list(callback, db_user, db)
async def handle_subscription_config_back(
callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession
):
+6 -5
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime
from aiogram import types
@@ -66,7 +67,7 @@ async def handle_add_countries(callback: types.CallbackQuery, db_user: User, db:
current_countries_names = []
for country in countries:
if country['uuid'] in current_countries:
current_countries_names.append(country['name'])
current_countries_names.append(html.escape(country['name']))
current_list = (
'\n'.join(f'{name}' for name in current_countries_names)
@@ -659,8 +660,8 @@ def _build_countries_selection_text(countries: list[dict], base_text: str) -> st
continue
desc = country.get('description', '').strip()
if desc:
name = country.get('name', '')
descriptions.append(f'<b>{name}</b>\n{desc}')
name = html.escape(country.get('name', ''))
descriptions.append(f'<b>{name}</b>\n{html.escape(desc)}')
if not descriptions:
return base_text
@@ -841,9 +842,9 @@ async def confirm_add_countries_to_subscription(
total_price += charged_price
total_discount_value += int(discount_per_month * charged_days / 30)
new_countries_names.append(country['name'])
new_countries_names.append(html.escape(country['name']))
if country['uuid'] in removed_countries:
removed_countries_names.append(country['name'])
removed_countries_names.append(html.escape(country['name']))
if new_countries and db_user.balance_kopeks < total_price:
missing_kopeks = total_price - db_user.balance_kopeks
+12 -4
View File
@@ -82,7 +82,7 @@ async def get_servers_display_names(squad_uuids: list[str]) -> str:
for uuid in squad_uuids:
server = await get_server_squad_by_uuid(db, uuid)
if server:
server_names.append(server.display_name)
server_names.append(html_mod.escape(server.display_name))
logger.debug('Найден сервер в БД', uuid=uuid, display_name=server.display_name)
else:
logger.warning('Сервер с UUID не найден в БД', uuid=uuid)
@@ -92,7 +92,7 @@ async def get_servers_display_names(squad_uuids: list[str]) -> str:
for uuid in squad_uuids:
for country in countries:
if country['uuid'] == uuid:
server_names.append(country['name'])
server_names.append(html_mod.escape(country['name']))
logger.debug('Найден сервер в кэше', uuid=uuid, country=country['name'])
break
@@ -606,7 +606,7 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
await db.commit()
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
from app.database.crud.subscription import reactivate_subscription
await reactivate_subscription(db, subscription)
@@ -614,6 +614,10 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if db_user.remnawave_uuid and subscription.status == 'active':
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
# При уменьшении лимита - удалить лишние устройства (последние подключённые)
devices_reset_count = 0
if new_devices_count < current_devices and db_user.remnawave_uuid:
@@ -1285,7 +1289,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
subscription.updated_at = datetime.now(UTC)
await db.commit()
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
from app.database.crud.subscription import reactivate_subscription
await reactivate_subscription(db, subscription)
@@ -1293,6 +1297,10 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if db_user.remnawave_uuid and subscription.status == 'active':
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
await create_transaction(
db=db,
user_id=db_user.id,
+3 -2
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime
from typing import Any
@@ -74,7 +75,7 @@ async def _prepare_subscription_summary(
if country['uuid'] in selected_country_ids:
server_price_per_month = country['price_kopeks']
countries_price_per_month += server_price_per_month
selected_countries_names.append(country['name'])
selected_countries_names.append(html.escape(country['name']))
server_monthly_prices.append(server_price_per_month)
servers_discount_percent = db_user.get_promo_discount(
@@ -472,7 +473,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
days_left=max(0, subscription.days_left),
traffic_used=texts.format_traffic(subscription.traffic_used_gb, is_limit=False),
traffic_limit=traffic_text,
countries_count=len(subscription.connected_squads),
countries_count=len(subscription.connected_squads or []),
devices_used=devices_used,
devices_limit=subscription.device_limit,
autopay_status='✅ Включен' if subscription.autopay_enabled else '⌛ Выключен',
+118 -360
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -96,12 +97,11 @@ from app.handlers.simple_subscription import (
_get_simple_subscription_payment_keyboard,
)
from app.states import SubscriptionStates
from app.utils.price_display import PriceInfo, calculate_user_price, format_price_text
from app.utils.price_display import PriceInfo, format_price_text
from app.utils.pricing_utils import (
apply_percentage_discount,
calculate_months_from_days,
format_period_description,
validate_pricing_calculation,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
@@ -117,7 +117,7 @@ from .autopay import (
show_autopay_days,
toggle_autopay,
)
from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, update_traffic_prices
from .common import _get_promo_offer_discount_percent, update_traffic_prices
from .countries import (
_build_countries_selection_text,
_get_available_countries,
@@ -208,7 +208,11 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
current_time = datetime.now(UTC)
if subscription.status == 'disabled':
if subscription.status == 'limited':
actual_status = 'limited'
status_display = texts.t('SUBSCRIPTION_STATUS_LIMITED', 'Трафик исчерпан')
status_emoji = '⚠️'
elif subscription.status == 'disabled':
actual_status = 'disabled'
status_display = texts.t('SUBSCRIPTION_STATUS_DISABLED', 'Приостановлена')
status_emoji = '⏸️'
@@ -623,7 +627,7 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
tariff_squads = await get_server_squads_by_uuids(db, trial_tariff.allowed_squads)
if tariff_squads:
if len(tariff_squads) == 1:
trial_server_name = tariff_squads[0].display_name
trial_server_name = html.escape(tariff_squads[0].display_name)
else:
trial_server_name = texts.t(
'TRIAL_SERVER_RANDOM_POOL',
@@ -633,7 +637,7 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
if trial_squads:
if len(trial_squads) == 1:
trial_server_name = trial_squads[0].display_name
trial_server_name = html.escape(trial_squads[0].display_name)
else:
trial_server_name = texts.t(
'TRIAL_SERVER_RANDOM_POOL',
@@ -1590,7 +1594,7 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
await callback.answer()
return
subscription_service = SubscriptionService()
from app.services.pricing_engine import pricing_engine
available_periods = settings.get_available_renewal_periods()
renewal_prices = {}
@@ -1598,72 +1602,18 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
for days in available_periods:
try:
months_in_period = calculate_months_from_days(days)
from app.config import PERIOD_PRICES
# 1. Calculate period price with promo group discount using unified system
base_price_original = PERIOD_PRICES.get(days, 0)
period_price_info = calculate_user_price(db_user, base_price_original, days, 'period')
# 2. Calculate servers price with promo group discount
servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids(
subscription.connected_squads,
pricing = await pricing_engine.calculate_renewal_price(
db,
promo_group_id=db_user.promo_group_id,
)
servers_total_base = servers_price_per_month * months_in_period
servers_price_info = calculate_user_price(db_user, servers_total_base, days, 'servers')
# 3. Calculate devices price with promo group discount
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_limit = forced_limit
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_total_base = devices_price_per_month * months_in_period
devices_price_info = calculate_user_price(db_user, devices_total_base, days, 'devices')
# 4. Calculate traffic price with promo group discount
# В режиме fixed_with_topup при продлении трафик сбрасывается до фиксированного лимита
if settings.is_traffic_fixed():
renewal_traffic_gb = settings.get_fixed_traffic_limit()
else:
renewal_traffic_gb = subscription.traffic_limit_gb
traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb)
traffic_total_base = traffic_price_per_month * months_in_period
traffic_price_info = calculate_user_price(db_user, traffic_total_base, days, 'traffic')
# 5. Calculate ORIGINAL price (before ALL discounts)
total_original_price = (
period_price_info.base_price
+ servers_price_info.base_price
+ devices_price_info.base_price
+ traffic_price_info.base_price
subscription,
days,
user=db_user,
)
# 6. Sum prices with promo group discounts applied
total_price = (
period_price_info.final_price
+ servers_price_info.final_price
+ devices_price_info.final_price
+ traffic_price_info.final_price
)
# original = price before ALL discounts, final = price with all discounts
total_original_price = pricing.original_total
# 7. Apply promo offer discount on top of promo group discounts
promo_component = _apply_promo_offer_discount(db_user, total_price)
# Store: original = price before discounts, final = price with all discounts
renewal_prices[days] = {
'final': promo_component['discounted'],
'final': pricing.final_total,
'original': total_original_price,
}
@@ -1723,7 +1673,7 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
f'Осталось дней: {subscription.days_left}',
'',
'<b>Ваша текущая конфигурация:</b>',
f'🌍 Серверов: {len(subscription.connected_squads)}',
f'🌍 Серверов: {len(subscription.connected_squads or [])}',
f'📊 Трафик: {texts.format_traffic(subscription.traffic_limit_gb)}',
]
@@ -1765,6 +1715,9 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
if not callback.data:
await callback.answer('⚠ Ошибка данных', show_alert=True)
return
days = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
@@ -1782,143 +1735,45 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
await callback.answer('⚠ У вас нет активной подписки', show_alert=True)
return
from app.services.pricing_engine import pricing_engine
from app.services.subscription_renewal_service import SubscriptionRenewalChargeError, SubscriptionRenewalService
months_in_period = calculate_months_from_days(days)
old_end_date = subscription.end_date
server_uuid_prices: dict[str, int] = {}
try:
from app.config import PERIOD_PRICES
base_price_original = PERIOD_PRICES.get(days, 0)
period_discount_percent = db_user.get_promo_discount('period', days)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
period_discount_percent,
)
subscription_service = SubscriptionService()
servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids(
subscription.connected_squads,
pricing = await pricing_engine.calculate_renewal_price(
db,
promo_group_id=db_user.promo_group_id,
)
servers_discount_percent = db_user.get_promo_discount(
'servers',
subscription,
days,
user=db_user,
)
total_servers_price = 0
total_servers_discount = 0
for squad_uuid, server_monthly_price in zip(
subscription.connected_squads, per_server_monthly_prices, strict=False
):
discount_per_month = server_monthly_price * servers_discount_percent // 100
discounted_per_month = server_monthly_price - discount_per_month
total_servers_price += discounted_per_month * months_in_period
total_servers_discount += discount_per_month * months_in_period
server_uuid_prices[squad_uuid] = discounted_per_month * months_in_period
discounted_servers_price_per_month = servers_price_per_month - (
servers_price_per_month * servers_discount_percent // 100
)
price = pricing.final_total
# Derive device_limit from subscription (same logic as engine)
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_limit = forced_limit
device_limit = settings.DEFAULT_DEVICE_LIMIT
additional_devices = max(0, (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',
days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_price_per_month = devices_price_per_month - devices_discount_per_month
total_devices_price = discounted_devices_price_per_month * months_in_period
# Derive renewal_traffic_gb for cart data
renewal_traffic_gb = subscription.traffic_limit_gb
# В режиме fixed_with_topup при продлении трафик сбрасывается до фиксированного лимита
if settings.is_traffic_fixed():
renewal_traffic_gb = settings.get_fixed_traffic_limit()
else:
renewal_traffic_gb = subscription.traffic_limit_gb
traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb)
traffic_discount_percent = db_user.get_promo_discount(
'traffic',
days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_price_per_month = traffic_price_per_month - traffic_discount_per_month
total_traffic_price = discounted_traffic_price_per_month * months_in_period
price = base_price + total_servers_price + total_devices_price + total_traffic_price
original_price = price
promo_component = _apply_promo_offer_discount(db_user, price)
if promo_component['discount'] > 0:
price = promo_component['discounted']
monthly_additions = (
discounted_servers_price_per_month + discounted_devices_price_per_month + discounted_traffic_price_per_month
)
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, original_price)
if not is_valid:
logger.error('Ошибка в расчете цены продления для пользователя', telegram_id=db_user.telegram_id)
await callback.answer('Ошибка расчета цены. Обратитесь в поддержку.', show_alert=True)
return
# Promo offer discount info for downstream consume_promo_offer flag
promo_offer_discount = pricing.promo_offer_discount
offer_pct = pricing.breakdown.get('offer_discount_pct', 0)
logger.info(
'💰 Расчет продления подписки на дней ( мес)',
'💰 Расчет продления подписки (PricingEngine)',
subscription_id=subscription.id,
days=days,
months_in_period=months_in_period,
base_price=pricing.base_price,
servers_price=pricing.servers_price,
traffic_price=pricing.traffic_price,
devices_price=pricing.devices_price,
group_discount=pricing.promo_group_discount,
offer_discount=pricing.promo_offer_discount,
final_total=pricing.final_total,
)
base_log = f' 📅 Период {days} дней: {base_price_original / 100}'
if base_discount_total > 0:
base_log += f'{base_price / 100}₽ (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)'
logger.info(base_log)
if total_servers_price > 0:
logger.info(
f' 🌐 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period}'
f' = {total_servers_price / 100}'
+ (
f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)'
if total_servers_discount > 0
else ''
)
)
if total_devices_price > 0:
logger.info(
f' 📱 Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period}'
f' = {total_devices_price / 100}'
+ (
f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period / 100}₽)'
if devices_discount_percent > 0 and devices_discount_per_month > 0
else ''
)
)
if total_traffic_price > 0:
logger.info(
f' 📊 Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period}'
f' = {total_traffic_price / 100}'
+ (
f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period / 100}₽)'
if traffic_discount_percent > 0 and traffic_discount_per_month > 0
else ''
)
)
if promo_component['discount'] > 0:
logger.info(
'🎯 Промо-предложение: -₽ (%)',
promo_component=promo_component['discount'] / 100,
promo_component_2=promo_component['percent'],
)
logger.info('💎 ИТОГО: ₽', price=price / 100)
except Exception as e:
@@ -1955,7 +1810,7 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
'missing_amount': missing_kopeks,
'return_to_cart': True,
'description': f'Продление подписки на {days} дней',
'consume_promo_offer': bool(promo_component['discount'] > 0),
'consume_promo_offer': bool(promo_offer_discount > 0),
'device_limit': device_limit,
'traffic_limit_gb': renewal_traffic_gb,
}
@@ -1974,183 +1829,57 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
await callback.answer()
return
old_traffic_gb = subscription.traffic_limit_gb
renewal_description = f'Продление подписки на {days} дней ({months_in_period} мес)'
try:
success = await subtract_user_balance(
renewal_service = SubscriptionRenewalService()
result = await renewal_service.finalize(
db,
db_user,
price,
f'Продление подписки на {days} дней',
consume_promo_offer=promo_component['discount'] > 0,
mark_as_paid_subscription=True,
subscription,
pricing,
description=renewal_description,
payment_method=PaymentMethod.BALANCE,
)
if not success:
await callback.answer('⚠ Ошибка списания средств', show_alert=True)
return
current_time = datetime.now(UTC)
was_expired = subscription.status in (
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
) or (subscription.end_date is not None and subscription.end_date <= current_time)
if subscription.end_date > current_time:
new_end_date = subscription.end_date + timedelta(days=days)
else:
new_end_date = current_time + timedelta(days=days)
subscription.end_date = new_end_date
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = current_time
# При продлении истёкшей подписки — сбрасываем докупки трафика
if was_expired:
from sqlalchemy import delete as sql_delete_tp
from app.database.models import TrafficPurchase as TrafficPurchaseModel
await db.execute(
sql_delete_tp(TrafficPurchaseModel).where(TrafficPurchaseModel.subscription_id == subscription.id)
)
purchased = subscription.purchased_traffic_gb or 0
if purchased > 0:
old_traffic = subscription.traffic_limit_gb
subscription.traffic_limit_gb = max(0, (subscription.traffic_limit_gb or 0) - purchased)
logger.info(
'Сброс докупок при продлении истёкшей подписки',
old_traffic=old_traffic,
new_traffic=subscription.traffic_limit_gb,
)
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
if settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
# В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита
traffic_was_reset = False
old_traffic_limit = subscription.traffic_limit_gb
if settings.is_traffic_fixed():
fixed_limit = settings.get_fixed_traffic_limit()
if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0:
traffic_was_reset = True
subscription.traffic_limit_gb = fixed_limit
from sqlalchemy import delete as sql_delete_fixed
from app.database.models import TrafficPurchase as TPFixed
await db.execute(sql_delete_fixed(TPFixed).where(TPFixed.subscription_id == subscription.id))
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
logger.info(
'🔄 Сброс трафика при продлении: ГБ → ГБ',
old_traffic_limit=old_traffic_limit,
fixed_limit=fixed_limit,
)
await db.commit()
await db.refresh(subscription)
await db.refresh(db_user)
# ensure freshly loaded values are available even if SQLAlchemy expires
# attributes on subsequent access
refreshed_end_date = subscription.end_date
refreshed_balance = db_user.balance_kopeks
from app.database.crud.server_squad import get_server_ids_by_uuids
from app.database.crud.subscription import add_subscription_servers
server_ids = await get_server_ids_by_uuids(db, subscription.connected_squads)
if server_ids:
from sqlalchemy import select
from app.database.models import ServerSquad
result = await db.execute(
select(ServerSquad.id, ServerSquad.squad_uuid).where(ServerSquad.id.in_(server_ids))
)
id_to_uuid = {row.id: row.squad_uuid for row in result}
default_price = total_servers_price // len(server_ids) if server_ids else 0
server_prices_for_period = [
server_uuid_prices.get(id_to_uuid.get(server_id, ''), default_price) for server_id in server_ids
]
await add_subscription_servers(db, subscription, server_ids, server_prices_for_period)
try:
remnawave_result = await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason='продление подписки',
)
if remnawave_result:
logger.info('✅ RemnaWave обновлен успешно')
else:
logger.error('⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE')
except Exception as e:
logger.error('⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE', error=e)
transaction = await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price,
description=f'Продление подписки на {days} дней ({months_in_period} мес)',
)
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_extension_notification(
db,
db_user,
subscription,
transaction,
days,
old_end_date,
new_end_date=refreshed_end_date,
balance_after=refreshed_balance,
)
except Exception as e:
logger.error('Ошибка отправки уведомления о продлении', error=e)
success_message = (
'✅ Подписка успешно продлена!\n\n'
f'⏰ Добавлено: {days} дней\n'
f'Действует до: {format_local_datetime(refreshed_end_date, "%d.%m.%Y %H:%M")}\n\n'
f'💰 Списано: {texts.format_price(price)}'
)
# Добавляем уведомление о сбросе трафика
if traffic_was_reset:
fixed_limit = settings.get_fixed_traffic_limit()
success_message += f'\n\n📊 Трафик сброшен до {fixed_limit} ГБ'
if promo_component['discount'] > 0:
success_message += (
f' (включая доп. скидку {promo_component["percent"]}%:'
f' -{texts.format_price(promo_component["discount"])})'
)
await callback.message.edit_text(success_message, reply_markup=get_back_keyboard(db_user.language))
logger.info(
'✅ Пользователь продлил подписку на дней за ₽',
telegram_id=db_user.telegram_id,
days=days,
price=price / 100,
)
except SubscriptionRenewalChargeError:
await callback.answer('⚠ Ошибка списания средств', show_alert=True)
return
except Exception as e:
logger.error('⚠ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ', error=e)
import traceback
logger.error('TRACEBACK', format_exc=traceback.format_exc())
await callback.message.edit_text(
'⚠ Произошла ошибка при продлении подписки. Обратитесь в поддержку.',
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
return
refreshed_end_date = result.subscription.end_date
await db.refresh(db_user)
success_message = (
'✅ Подписка успешно продлена!\n\n'
f'⏰ Добавлено: {days} дней\n'
f'Действует до: {format_local_datetime(refreshed_end_date, "%d.%m.%Y %H:%M")}\n\n'
f'💰 Списано: {texts.format_price(price)}'
)
# Добавляем уведомление о сбросе трафика
if settings.is_traffic_fixed() and result.subscription.traffic_limit_gb != old_traffic_gb:
fixed_limit = settings.get_fixed_traffic_limit()
success_message += f'\n\n📊 Трафик сброшен до {fixed_limit} ГБ'
if promo_offer_discount > 0:
success_message += f' (включая доп. скидку {offer_pct}%: -{texts.format_price(promo_offer_discount)})'
await callback.message.edit_text(success_message, reply_markup=get_back_keyboard(db_user.language))
logger.info(
'✅ Пользователь продлил подписку на дней за ₽',
telegram_id=db_user.telegram_id,
days=days,
price=price / 100,
)
await callback.answer()
@@ -3151,7 +2880,7 @@ async def handle_subscription_settings(callback: types.CallbackQuery, db_user: U
devices_limit_display = str(subscription.device_limit)
settings_text = settings_template.format(
countries_count=len(subscription.connected_squads),
countries_count=len(subscription.connected_squads or []),
traffic_used=texts.format_traffic(subscription.traffic_used_gb, is_limit=False),
traffic_limit=texts.format_traffic(subscription.traffic_limit_gb, is_limit=True),
devices_used=devices_used,
@@ -3215,7 +2944,11 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
from app.database.models import SubscriptionStatus
was_paused = getattr(subscription, 'is_daily_paused', False)
is_inactive = subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value)
is_inactive = subscription.status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
)
needs_resume = was_paused or is_inactive
# При возобновлении проверяем баланс
@@ -4547,7 +4280,32 @@ async def _extend_existing_subscription(
current_subscription.updated_at = current_time
# Сохраняем изменения
await db.commit()
try:
await db.commit()
except Exception as commit_error:
logger.error('Ошибка сохранения продления подписки', error=commit_error, exc_info=True)
await db.rollback()
# Compensating refund: balance was already committed by subtract_user_balance
try:
from app.database.crud.user import add_user_balance
await add_user_balance(
db,
db_user,
price_kopeks,
'Возврат: ошибка продления подписки',
create_transaction=True,
transaction_type=TransactionType.REFUND,
)
except Exception as refund_error:
logger.critical(
'CRITICAL: не удалось вернуть средства после ошибки продления',
user_id=db_user.id,
price_kopeks=price_kopeks,
refund_error=refund_error,
)
await callback.answer('⚠ Ошибка продления подписки', show_alert=True)
return
await db.refresh(current_subscription)
await db.refresh(db_user)
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -577,12 +577,16 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
# add_subscription_traffic уже создаёт TrafficPurchase и обновляет все необходимые поля
await add_subscription_traffic(db, subscription, traffic_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 db_user.remnawave_uuid and subscription.status == 'active':
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
await create_transaction(
db=db,
user_id=db_user.id,
@@ -834,12 +838,16 @@ async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, d
await db.commit()
# Реактивируем подписку если она была 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 db_user.remnawave_uuid and subscription.status == 'active':
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
await db.refresh(db_user)
await db.refresh(subscription)
+77 -2
View File
@@ -1146,7 +1146,11 @@ def get_subscription_keyboard(
sub_status = getattr(subscription, 'status', None)
is_paused = getattr(subscription, 'is_daily_paused', False)
is_inactive = sub_status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value)
is_inactive = sub_status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
)
if is_inactive or is_paused:
# Подписка остановлена (системой или пользователем) — показываем «Возобновить»
@@ -1500,8 +1504,17 @@ def get_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark
InlineKeyboardButton(text=texts.BALANCE_HISTORY, callback_data='balance_history'),
InlineKeyboardButton(text=texts.BALANCE_TOP_UP, callback_data='balance_topup'),
],
[InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')],
]
if settings.YOOKASSA_RECURRENT_ENABLED:
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('SAVED_CARDS_BUTTON', '💳 Привязанные карты'),
callback_data='saved_cards_list',
)
]
)
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -1937,6 +1950,68 @@ def get_autopay_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark
)
_PAYMENT_METHOD_LOCALE_KEYS: dict[str, tuple[str, str]] = {
'bank_card': ('PAYMENT_METHOD_BANK_CARD', '💳 Банковская карта'),
'yoo_money': ('PAYMENT_METHOD_YOO_MONEY', '🟣 ЮMoney'),
'sberbank': ('PAYMENT_METHOD_SBERBANK', '🟢 СберPay'),
'tinkoff_bank': ('PAYMENT_METHOD_TINKOFF_BANK', '🟡 Т-Банк'),
'sbp': ('PAYMENT_METHOD_SBP', '🏦 СБП'),
'mir_pay': ('PAYMENT_METHOD_MIR_PAY', '🟦 Mir Pay'),
}
def _get_payment_method_display_name(card, language: str = DEFAULT_LANGUAGE) -> str:
"""Локализованное название метода оплаты + реквизиты."""
texts = get_texts(language)
# Для банковских карт title уже содержит тип + маску (например "Visa *4444")
if card.method_type == 'bank_card' or (not card.method_type and card.card_last4):
if card.title:
return card.title
if card.card_last4:
return f'{card.card_type or "Card"} *{card.card_last4}'
# Для остальных методов: локализованное название + реквизиты из title
locale_entry = _PAYMENT_METHOD_LOCALE_KEYS.get(card.method_type)
if locale_entry:
key, default = locale_entry
method_name = texts.t(key, default)
else:
method_name = card.method_type or 'Card'
if card.title:
return f'{method_name} {card.title}'
return method_name
def get_saved_cards_keyboard(cards: list, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
for card in cards:
card_label = f'🗑 {_get_payment_method_display_name(card, language)}'
keyboard.append([InlineKeyboardButton(text=card_label, callback_data=f'unlink_card_{card.id}')])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_confirm_unlink_keyboard(card_id: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('SAVED_CARDS_CONFIRM_YES', '✅ Да, отвязать'),
callback_data=f'confirm_unlink_{card_id}',
),
InlineKeyboardButton(
text=texts.t('CANCEL', '❌ Отмена'),
callback_data='saved_cards_list',
),
]
]
)
def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
+14
View File
@@ -899,6 +899,20 @@
"AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Auto-payment completed</b>\n\nBalance topped up by {amount} for subscription renewal.",
"RECURRENT_TOPUP_FAILED": "❌ <b>Auto-payment failed</b>\n\nCould not charge {amount} from any saved card for subscription renewal.\n\nPlease top up your balance manually to avoid service interruption.",
"SAVED_CARDS_BUTTON": "💳 Saved cards",
"SAVED_CARDS_TITLE": "💳 <b>Saved cards</b>\n\nSelect a card to unlink:",
"SAVED_CARDS_EMPTY": "💳 <b>Saved cards</b>\n\nNo saved cards.\nA card will be saved automatically on your next balance top-up.",
"SAVED_CARDS_CONFIRM_UNLINK": "Are you sure you want to unlink <b>{card}</b>?\n\nAfter unlinking, autopay won't be able to use this card.",
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>Warning:</b> this is your last saved card. After unlinking, autopay won't be able to charge payments.",
"SAVED_CARDS_UNLINKED": "✅ Card unlinked",
"SAVED_CARDS_UNLINK_ERROR": "❌ Failed to unlink card",
"SAVED_CARDS_CONFIRM_YES": "✅ Yes, unlink",
"PAYMENT_METHOD_BANK_CARD": "💳 Bank card",
"PAYMENT_METHOD_YOO_MONEY": "🟣 YooMoney",
"PAYMENT_METHOD_SBERBANK": "🟢 SberPay",
"PAYMENT_METHOD_TINKOFF_BANK": "🟡 T-Bank",
"PAYMENT_METHOD_SBP": "🏦 SBP",
"PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay",
"BACK": "⬅️ Back",
"BACK_BUTTON": "◀️ Back",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu",
+14
View File
@@ -919,6 +919,20 @@
"AUTOPAY_TOGGLE_SUCCESS": "✅ پرداخت خودکار {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>پرداخت خودکار انجام شد</b>\n\nموجودی به مبلغ {amount} برای تمدید اشتراک شارژ شد.",
"RECURRENT_TOPUP_FAILED": "❌ <b>پرداخت خودکار ناموفق بود</b>\n\nامکان کسر {amount} از هیچ کارت ذخیره شده‌ای برای تمدید اشتراک وجود نداشت.\n\nلطفاً موجودی را به صورت دستی شارژ کنید.",
"SAVED_CARDS_BUTTON": "💳 کارت‌های ذخیره شده",
"SAVED_CARDS_TITLE": "💳 <b>کارت‌های ذخیره شده</b>\n\nکارت مورد نظر برای حذف را انتخاب کنید:",
"SAVED_CARDS_EMPTY": "💳 <b>کارت‌های ذخیره شده</b>\n\nکارت ذخیره شده‌ای وجود ندارد.\nکارت به صورت خودکار در شارژ بعدی موجودی ذخیره می‌شود.",
"SAVED_CARDS_CONFIRM_UNLINK": "آیا مطمئن هستید که می‌خواهید کارت <b>{card}</b> را حذف کنید؟\n\nپس از حذف، پرداخت خودکار نمی‌تواند از این کارت استفاده کند.",
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>توجه:</b> این آخرین کارت ذخیره شده شماست. پس از حذف، پرداخت خودکار امکان برداشت وجه نخواهد داشت.",
"SAVED_CARDS_UNLINKED": "✅ کارت حذف شد",
"SAVED_CARDS_UNLINK_ERROR": "❌ حذف کارت انجام نشد",
"SAVED_CARDS_CONFIRM_YES": "✅ بله، حذف کن",
"PAYMENT_METHOD_BANK_CARD": "💳 کارت بانکی",
"PAYMENT_METHOD_YOO_MONEY": "🟣 ЮMoney",
"PAYMENT_METHOD_SBERBANK": "🟢 СберPay",
"PAYMENT_METHOD_TINKOFF_BANK": "🟡 Т-Банк",
"PAYMENT_METHOD_SBP": "🏦 СБП",
"PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay",
"BACK": "⬅️ قبلی",
"BACK_BUTTON": "◀️ بازگشت",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ منوی اصلی",
+14
View File
@@ -919,6 +919,20 @@
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Автоплатёж выполнен</b>\n\nБаланс пополнен на {amount} для продления подписки.",
"RECURRENT_TOPUP_FAILED": "❌ <b>Автоплатёж не удался</b>\n\nНе удалось списать {amount} ни с одной сохранённой карты для продления подписки.\n\nПополните баланс вручную, чтобы подписка не прервалась.",
"SAVED_CARDS_BUTTON": "💳 Привязанные карты",
"SAVED_CARDS_TITLE": "💳 <b>Привязанные карты</b>\n\nВыберите карту для отвязки:",
"SAVED_CARDS_EMPTY": "💳 <b>Привязанные карты</b>\n\nНет привязанных карт.\nКарта привяжется автоматически при следующем пополнении баланса.",
"SAVED_CARDS_CONFIRM_UNLINK": "Вы уверены, что хотите отвязать карту <b>{card}</b>?\n\nПосле отвязки автоплатеж не сможет использовать эту карту.",
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>Внимание:</b> это ваша последняя привязанная карта. После отвязки автоплатеж не сможет списывать средства.",
"SAVED_CARDS_UNLINKED": "✅ Карта отвязана",
"SAVED_CARDS_UNLINK_ERROR": "❌ Не удалось отвязать карту",
"SAVED_CARDS_CONFIRM_YES": "✅ Да, отвязать",
"PAYMENT_METHOD_BANK_CARD": "💳 Банковская карта",
"PAYMENT_METHOD_YOO_MONEY": "🟣 ЮMoney",
"PAYMENT_METHOD_SBERBANK": "🟢 СберPay",
"PAYMENT_METHOD_TINKOFF_BANK": "🟡 Т-Банк",
"PAYMENT_METHOD_SBP": "🏦 СБП",
"PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay",
"BACK": "⬅️ Назад",
"BACK_BUTTON": "◀️ Назад",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню",
+14
View File
@@ -841,6 +841,20 @@
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатіж {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Автоплатіж виконано</b>\n\nБаланс поповнено на {amount} для продовження підписки.",
"RECURRENT_TOPUP_FAILED": "❌ <b>Автоплатіж не вдався</b>\n\nНе вдалося списати {amount} з жодної збереженої картки для продовження підписки.\n\nПоповніть баланс вручну, щоб підписка не перервалася.",
"SAVED_CARDS_BUTTON": "💳 Прив'язані картки",
"SAVED_CARDS_TITLE": "💳 <b>Прив'язані картки</b>\n\nОберіть картку для відв'язки:",
"SAVED_CARDS_EMPTY": "💳 <b>Прив'язані картки</b>\n\nНемає прив'язаних карток.\nКартка прив'яжеться автоматично при наступному поповненні балансу.",
"SAVED_CARDS_CONFIRM_UNLINK": "Ви впевнені, що хочете відв'язати картку <b>{card}</b>?\n\nПісля відв'язки автоплатіж не зможе використовувати цю картку.",
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>Увага:</b> це ваша остання прив'язана картка. Після відв'язки автоплатіж не зможе списувати кошти.",
"SAVED_CARDS_UNLINKED": "✅ Картку відв'язано",
"SAVED_CARDS_UNLINK_ERROR": "❌ Не вдалося відв'язати картку",
"SAVED_CARDS_CONFIRM_YES": "✅ Так, відв'язати",
"PAYMENT_METHOD_BANK_CARD": "💳 Банківська картка",
"PAYMENT_METHOD_YOO_MONEY": "🟣 ЮMoney",
"PAYMENT_METHOD_SBERBANK": "🟢 СберPay",
"PAYMENT_METHOD_TINKOFF_BANK": "🟡 Т-Банк",
"PAYMENT_METHOD_SBP": "🏦 СБП",
"PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay",
"BACK": "⬅️ Назад",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В головне меню",
"BACK_TO_MENU": "🏠 В головне меню",
+14
View File
@@ -839,6 +839,20 @@
"AUTOPAY_TOGGLE_SUCCESS": "✅自动支付{status}",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>自动扣款成功</b>\n\n余额已充值{amount},用于续订订阅。",
"RECURRENT_TOPUP_FAILED": "❌ <b>自动扣款失败</b>\n\n无法从任何已保存的银行卡中扣除{amount}以续订订阅。\n\n请手动充值余额以避免服务中断。",
"SAVED_CARDS_BUTTON": "💳 已绑定的卡",
"SAVED_CARDS_TITLE": "💳 <b>已绑定的卡</b>\n\n选择要解绑的卡:",
"SAVED_CARDS_EMPTY": "💳 <b>已绑定的卡</b>\n\n没有已绑定的卡。\n下次充值余额时将自动绑定。",
"SAVED_CARDS_CONFIRM_UNLINK": "确定要解绑 <b>{card}</b> 吗?\n\n解绑后自动扣款将无法使用此卡。",
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>注意:</b>这是您最后一张绑定的卡。解绑后自动扣款将无法进行。",
"SAVED_CARDS_UNLINKED": "✅ 卡已解绑",
"SAVED_CARDS_UNLINK_ERROR": "❌ 解绑失败",
"SAVED_CARDS_CONFIRM_YES": "✅ 确认解绑",
"PAYMENT_METHOD_BANK_CARD": "💳 银行卡",
"PAYMENT_METHOD_YOO_MONEY": "🟣 ЮMoney",
"PAYMENT_METHOD_SBERBANK": "🟢 СберPay",
"PAYMENT_METHOD_TINKOFF_BANK": "🟡 Т-Банк",
"PAYMENT_METHOD_SBP": "🏦 СБП",
"PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay",
"BACK": "⬅️返回",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️返回主菜单",
"BACK_TO_MENU": "🏠返回主菜单",
+9
View File
@@ -446,6 +446,15 @@ async def execute_merge(
# 4. Суммируем баланс (включая отрицательный — долг не должен исчезать)
transferred_kopeks = secondary.balance_kopeks
if transferred_kopeks != 0:
from app.database.models import User as UserModel
if isinstance(primary, UserModel):
from app.database.crud.user import lock_user_for_update
primary = await lock_user_for_update(db, primary)
secondary = await lock_user_for_update(db, secondary)
# Re-read after lock in case concurrent payment changed it
transferred_kopeks = secondary.balance_kopeks
primary.balance_kopeks += transferred_kopeks
secondary.balance_kopeks = 0
logger.info(
+3
View File
@@ -306,6 +306,9 @@ class ContestAttemptService:
return ''
kopeks = int(prize_value) if prize_value.isdigit() else 0
if kopeks > 0:
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
user.balance_kopeks += kopeks
await db.commit()
return texts.t('CONTEST_BALANCE_GRANTED', 'Бонус {amount} зачислен!').format(
+89 -6
View File
@@ -17,7 +17,17 @@ from app.config import settings
from app.database.crud.landing import create_guest_purchase
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, replace_subscription
from app.database.crud.tariff import get_tariff_by_id
from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage, Tariff, User
from app.database.crud.transaction import create_transaction
from app.database.crud.user import _get_or_create_default_promo_group
from app.database.models import (
GuestPurchase,
GuestPurchaseStatus,
LandingPage,
PaymentMethod,
Tariff,
TransactionType,
User,
)
from app.services.subscription_service import SubscriptionService
@@ -242,7 +252,7 @@ async def fulfill_purchase(
# Active subscription or gift with any existing subscription — hold for manual activation
purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
purchase.user_id = user.id
if recipient_type == 'email' and not purchase.is_gift:
if recipient_type == 'email' and not purchase.is_gift and is_new_account:
purchase.auto_login_token = create_auto_login_token(user.id)
await db.commit()
await db.refresh(purchase, attribute_names=['landing', 'user'])
@@ -310,12 +320,28 @@ async def fulfill_purchase(
purchase.status = GuestPurchaseStatus.DELIVERED.value
purchase.user_id = user.id
purchase.delivered_at = datetime.now(UTC)
if recipient_type == 'email' and not purchase.is_gift:
if recipient_type == 'email' and not purchase.is_gift and is_new_account:
purchase.auto_login_token = create_auto_login_token(user.id)
await db.commit()
await db.refresh(purchase, attribute_names=['landing', 'user'])
# Create transaction so promo group auto-assignment and contest tracking work
try:
payment_method_enum = _resolve_payment_method(purchase.payment_method)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=purchase.amount_kopeks,
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
payment_method=payment_method_enum,
external_id=purchase.payment_id,
is_completed=True,
)
except Exception:
logger.exception('Failed to create transaction for guest purchase', purchase_id=purchase.id)
try:
await send_guest_notification(
purchase,
@@ -357,6 +383,26 @@ async def fulfill_purchase(
return purchase
def _resolve_payment_method(method_str: str | None) -> PaymentMethod | None:
"""Convert payment method string from GuestPurchase to PaymentMethod enum."""
if not method_str:
return None
# Try exact match first (handles 'telegram_stars', 'kassa_ai', 'yookassa', etc.)
try:
return PaymentMethod(method_str)
except ValueError:
pass
# Strip sub-option suffix ('yookassa_sbp' → 'yookassa', 'platega_2' → 'platega')
if '_' in method_str:
base_method = method_str.split('_')[0]
try:
return PaymentMethod(base_method)
except ValueError:
pass
logger.debug('Unknown payment method for transaction', method=method_str)
return None
def _mask_email(email: str) -> str:
"""Mask email for logging: 'user@example.com' -> 'u***@e***.com'."""
if not email:
@@ -399,8 +445,8 @@ async def _find_or_create_user(
user = result.scalars().first()
if user:
is_new_account = False
# Existing user WITHOUT password — generate one and set up cabinet access
if not user.password_hash:
# User without cabinet access — generate credentials
plain_password = secrets.token_urlsafe(12)
user.password_hash = hash_password(plain_password)
if purchase:
@@ -410,16 +456,22 @@ async def _find_or_create_user(
if not user.email_verified:
user.email_verified = True
user.email_verified_at = datetime.now(UTC)
# Ensure default promo group
if not user.promo_group_id:
default_group = await _get_or_create_default_promo_group(db)
user.promo_group_id = default_group.id
return user, is_new_account
# Create new email user with verified cabinet account
plain_password = secrets.token_urlsafe(12)
default_group = await _get_or_create_default_promo_group(db)
user = User(
auth_type='email',
email=contact_value,
email_verified=True,
email_verified_at=datetime.now(UTC),
password_hash=hash_password(plain_password),
promo_group_id=default_group.id,
)
if purchase:
purchase.cabinet_password = plain_password
@@ -431,7 +483,7 @@ async def _find_or_create_user(
result = await db.execute(select(User).where(User.email == contact_value))
user = result.scalars().first()
if user:
# Clear stale password from failed insert, then check if re-fetched user needs one
# Race condition — user was created concurrently
if purchase:
purchase.cabinet_password = None
is_new_account = False
@@ -444,6 +496,9 @@ async def _find_or_create_user(
if not user.email_verified:
user.email_verified = True
user.email_verified_at = datetime.now(UTC)
if not user.promo_group_id:
default_group = await _get_or_create_default_promo_group(db)
user.promo_group_id = default_group.id
return user, is_new_account
raise
logger.info(
@@ -508,13 +563,19 @@ async def _find_or_create_user(
resolved_telegram_id=resolved_telegram_id,
)
await db.refresh(user)
# Ensure default promo group
if not user.promo_group_id:
default_group = await _get_or_create_default_promo_group(db)
user.promo_group_id = default_group.id
return user, False
# Create new telegram user
default_group = await _get_or_create_default_promo_group(db)
user = User(
auth_type='telegram',
username=username,
telegram_id=resolved_telegram_id,
promo_group_id=default_group.id,
)
try:
async with db.begin_nested():
@@ -525,10 +586,16 @@ async def _find_or_create_user(
result = await db.execute(select(User).where(User.telegram_id == resolved_telegram_id))
user = result.scalars().first()
if user:
if not user.promo_group_id:
default_group = await _get_or_create_default_promo_group(db)
user.promo_group_id = default_group.id
return user, False
result = await db.execute(select(User).where(func.lower(User.username) == normalized))
user = result.scalars().first()
if user:
if not user.promo_group_id:
default_group = await _get_or_create_default_promo_group(db)
user.promo_group_id = default_group.id
return user, False
raise
logger.info(
@@ -854,13 +921,29 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
purchase.subscription_crypto_link = subscription.subscription_crypto_link
purchase.status = GuestPurchaseStatus.DELIVERED.value
purchase.delivered_at = datetime.now(UTC)
if user.auth_type == 'email' and not purchase.is_gift:
if user.auth_type == 'email' and not purchase.is_gift and is_new_account:
purchase.auto_login_token = create_auto_login_token(user.id)
# Single atomic commit: subscription + purchase status + user changes
await db.commit()
await db.refresh(purchase, attribute_names=['landing', 'user'])
# Create transaction so promo group auto-assignment and contest tracking work
try:
payment_method_enum = _resolve_payment_method(purchase.payment_method)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=purchase.amount_kopeks,
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
payment_method=payment_method_enum,
external_id=purchase.payment_id,
is_completed=True,
)
except Exception:
logger.exception('Failed to create transaction for activated purchase', purchase_id=purchase.id)
if not skip_notification:
try:
await send_guest_notification(
+5 -2
View File
@@ -1053,12 +1053,15 @@ class MonitoringService:
autopay_period = 30
try:
renewal_cost = await self.subscription_service.calculate_renewal_price(
from app.services.pricing_engine import pricing_engine
pricing = await pricing_engine.calculate_renewal_price(
db,
subscription,
autopay_period,
db,
user=user,
)
renewal_cost = pricing.final_total
except Exception as e:
logger.error(
'Ошибка расчёта стоимости автопродления, пропускаем',
+5
View File
@@ -271,6 +271,11 @@ class CloudPaymentsPaymentMixin:
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
+66 -26
View File
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import PaymentMethod, TransactionType
from app.services.pricing_engine import RenewalPricing, pricing_engine
from app.services.subscription_renewal_service import (
RenewalPaymentDescriptor,
SubscriptionRenewalChargeError,
@@ -163,7 +164,13 @@ class CryptoBotPaymentMixin:
else:
paid_at = datetime.now(UTC)
updated_payment = await cryptobot_crud.update_cryptobot_payment_status(db, invoice_id, status, paid_at)
updated_payment = await cryptobot_crud.update_cryptobot_payment_status(
db,
invoice_id,
status,
paid_at,
commit=False,
)
descriptor = decode_payment_payload(
getattr(updated_payment, 'payload', '') or '',
@@ -290,6 +297,11 @@ class CryptoBotPaymentMixin:
logger.error('Пользователь с ID не найден при пополнении баланса', user_id=updated_payment.user_id)
return False
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
@@ -404,7 +416,7 @@ class CryptoBotPaymentMixin:
except Exception as error:
logger.error(
'Не удалось загрузить пользователя для продления через CryptoBot',
getattr=getattr(payment, 'user_id', None),
payment_user_id=getattr(payment, 'user_id', None),
error=error,
)
return False
@@ -412,7 +424,7 @@ class CryptoBotPaymentMixin:
if not user:
logger.error(
'Пользователь не найден при обработке продления через CryptoBot',
getattr=getattr(payment, 'user_id', None),
payment_user_id=getattr(payment, 'user_id', None),
)
return False
@@ -420,12 +432,27 @@ class CryptoBotPaymentMixin:
if not subscription or subscription.id != descriptor.subscription_id:
logger.warning(
'Продление через CryptoBot отклонено: подписка не совпадает с ожидаемой',
getattr=getattr(subscription, 'id', None),
subscription_id=descriptor.subscription_id,
current_subscription_id=getattr(subscription, 'id', None),
expected_subscription_id=descriptor.subscription_id,
)
return False
pricing_model: SubscriptionRenewalPricing | None = None
# Validate period_days against allowed periods
tariff = getattr(subscription, 'tariff', None)
if tariff and tariff.period_prices:
allowed_periods = [int(p) for p in tariff.period_prices.keys()]
else:
allowed_periods = settings.get_available_renewal_periods()
if descriptor.period_days not in allowed_periods:
logger.error(
'CryptoBot renewal rejected: period_days not in allowed periods',
invoice_id=payment.invoice_id,
period_days=descriptor.period_days,
allowed_periods=allowed_periods,
)
return False
pricing_model: SubscriptionRenewalPricing | RenewalPricing | None = None
if descriptor.pricing_snapshot:
try:
pricing_model = SubscriptionRenewalPricing.from_payload(descriptor.pricing_snapshot)
@@ -438,11 +465,11 @@ class CryptoBotPaymentMixin:
if pricing_model is None:
try:
pricing_model = await renewal_service.calculate_pricing(
pricing_model = await pricing_engine.calculate_renewal_price(
db,
user,
subscription,
descriptor.period_days,
user=user,
)
except Exception as error:
logger.error(
@@ -454,27 +481,40 @@ class CryptoBotPaymentMixin:
if pricing_model.final_total != descriptor.total_amount_kopeks:
logger.warning(
'Сумма продления через CryptoBot изменилась (ожидалось , получено)',
'Сумма продления через CryptoBot изменилась',
invoice_id=payment.invoice_id,
total_amount_kopeks=descriptor.total_amount_kopeks,
final_total=pricing_model.final_total,
expected_kopeks=descriptor.total_amount_kopeks,
actual_kopeks=pricing_model.final_total,
)
pricing_model.final_total = descriptor.total_amount_kopeks
pricing_model.per_month = (
descriptor.total_amount_kopeks // pricing_model.months
if pricing_model.months
else descriptor.total_amount_kopeks
if pricing_model.final_total > descriptor.total_amount_kopeks:
# Price increased since invoice creation — user would be undercharged.
# Reject and let the user create a new invoice at the current price.
logger.error(
'CryptoBot renewal rejected: recalculated price exceeds agreed amount',
invoice_id=payment.invoice_id,
agreed_kopeks=descriptor.total_amount_kopeks,
recalculated_kopeks=pricing_model.final_total,
)
return False
# Price decreased — charge recalculated (lower) amount, user benefits
logger.info(
'CryptoBot renewal: price decreased, user benefits',
invoice_id=payment.invoice_id,
agreed_kopeks=descriptor.total_amount_kopeks,
recalculated_kopeks=pricing_model.final_total,
delta_kopeks=descriptor.total_amount_kopeks - pricing_model.final_total,
)
pricing_model.period_days = descriptor.period_days
pricing_model.period_id = build_renewal_period_id(descriptor.period_days)
# Override period_days/period_id only on mutable SubscriptionRenewalPricing
if isinstance(pricing_model, SubscriptionRenewalPricing):
pricing_model.period_days = descriptor.period_days
pricing_model.period_id = build_renewal_period_id(descriptor.period_days)
# When price drops, recalculate balance portion: total minus the fixed external payment
# This ensures the user isn't overcharged from balance when crypto already covers more
required_balance = max(
0,
min(
pricing_model.final_total,
descriptor.balance_component_kopeks,
),
pricing_model.final_total - descriptor.missing_amount_kopeks,
)
current_balance = getattr(user, 'balance_kopeks', 0)
@@ -606,10 +646,10 @@ class CryptoBotPaymentMixin:
reply_markup=payload.reply_markup,
)
logger.info(
'Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)',
payload.telegram_id,
f'{payload.amount_rubles:.2f}',
payload.asset,
'Отправлено уведомление пользователю о пополнении',
telegram_id=payload.telegram_id,
amount_rubles=f'{payload.amount_rubles:.2f}',
asset=payload.asset,
)
except Exception as error:
logger.error('Ошибка отправки уведомления о пополнении CryptoBot', error=error)
+5
View File
@@ -307,6 +307,11 @@ class FreekassaPaymentMixin:
payment.updated_at = datetime.now(UTC)
await db.flush()
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
+5
View File
@@ -352,6 +352,11 @@ class HeleketPaymentMixin:
logger.error('Пользователь не найден для Heleket платежа', user_id=updated_payment.user_id)
return None
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
+5
View File
@@ -295,6 +295,11 @@ class KassaAiPaymentMixin:
payment.updated_at = datetime.now(UTC)
await db.flush()
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
+5
View File
@@ -283,6 +283,11 @@ class MulenPayPaymentMixin:
)
return False
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
+5
View File
@@ -396,6 +396,11 @@ class Pal24PaymentMixin:
await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id)
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
+5
View File
@@ -402,6 +402,11 @@ class PlategaPaymentMixin:
logger.info('Platega платеж уже зачислил баланс ранее', correlation_id=payment.correlation_id)
return payment
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
+5
View File
@@ -384,6 +384,11 @@ class TelegramStarsMixin:
) -> bool:
"""Начисляет баланс пользователю после оплаты Stars и запускает автопокупку."""
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
# Запоминаем старые значения, чтобы корректно построить уведомления.
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
+5
View File
@@ -482,6 +482,11 @@ class WataPaymentMixin:
await payment_module.link_wata_payment_to_transaction(db, payment, transaction.id)
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
+102 -11
View File
@@ -20,7 +20,9 @@ from app.utils.user_utils import format_referrer_info
if TYPE_CHECKING:
from app.database.models import Transaction, YooKassaPayment
from app.database.models import Transaction, User, YooKassaPayment
_INT32_MAX = 2_147_483_647
class YooKassaPaymentMixin:
@@ -617,6 +619,7 @@ class YooKassaPaymentMixin:
external_id=payment.yookassa_payment_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
if not getattr(payment, 'transaction_id', None):
@@ -740,8 +743,13 @@ class YooKassaPaymentMixin:
'Ошибка реферального начисления при покупке подписки YooKassa', ref_error=ref_error
)
else:
old_balance = getattr(user, 'balance_kopeks', 0)
was_first_topup = not getattr(user, 'has_made_first_topup', False)
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.now(UTC)
@@ -783,6 +791,22 @@ class YooKassaPaymentMixin:
await db.commit()
# Emit deferred side-effects after atomic commit
try:
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=transaction_type,
payment_method=PaymentMethod.YOOKASSA,
external_id=payment.yookassa_payment_id,
)
except Exception as error:
logger.warning('Failed to emit YooKassa transaction side effects', error=error)
try:
from app.services.referral_service import process_referral_topup
@@ -1140,11 +1164,18 @@ class YooKassaPaymentMixin:
expiry_year = str(raw_year) if raw_year is not None else None
method_type = pm.get('type', 'bank_card')
# Формируем название
# Формируем title — только реквизиты без названия метода
# (локализованное название подставляется в UI через _get_payment_method_display_name)
title = None
if card_last4:
type_label = card_type or 'Card'
title = f'{type_label} *{card_last4}'
elif method_type != 'bank_card':
# Для не-карточных методов: yoo_money (account_number), sbp/sberbank (phone) и т.д.
account = pm.get('account_number') or pm.get('phone')
if account:
masked = account[-4:] if len(account) >= 4 else account
title = f'*{masked}'
saved = await create_saved_payment_method(
db=db,
@@ -1334,7 +1365,9 @@ class YooKassaPaymentMixin:
return None
metadata = self._normalise_yookassa_metadata(event_object.get('metadata'))
user_id_raw = metadata.get('user_id') or metadata.get('userId')
user_id_raw = metadata.get('user_id')
if user_id_raw is None:
user_id_raw = metadata.get('userId')
if user_id_raw is None:
logger.error(
@@ -1353,21 +1386,79 @@ class YooKassaPaymentMixin:
)
return None
# Verify user exists before creating FK-linked record
try:
from app.database.crud.user import get_user_by_id
if user_id <= 0:
logger.error(
'Webhook YooKassa содержит неположительный user_id',
yookassa_payment_id=yookassa_payment_id,
user_id=user_id,
)
return None
# Verify user exists before creating FK-linked record.
# Legacy payments may have telegram_id stored in metadata['user_id']
# instead of the internal User.id. Detect by checking int32 range.
user: User | None = None
try:
from app.database.crud.user import get_user_by_id, get_user_by_telegram_id
if user_id <= _INT32_MAX:
user = await get_user_by_id(db, user_id)
# Cross-validate: if metadata also has telegram_id, verify it matches
if user:
meta_tg = metadata.get('user_telegram_id') or metadata.get('userTelegramId')
if meta_tg is not None:
try:
expected_tg = int(meta_tg)
except (TypeError, ValueError):
expected_tg = None
if expected_tg and user.telegram_id != expected_tg:
logger.warning(
'Webhook YooKassa: user_id совпал, но telegram_id не совпадает — '
'вероятно legacy metadata, ищем по telegram_id',
yookassa_payment_id=yookassa_payment_id,
user_id=user_id,
user_telegram_id=user.telegram_id,
expected_telegram_id=expected_tg,
)
user = await get_user_by_telegram_id(db, expected_tg)
else:
# user_id exceeds int32 — это telegram_id из legacy-платежа
logger.warning(
'Webhook YooKassa: metadata[user_id] превышает int32, ищем как telegram_id',
yookassa_payment_id=yookassa_payment_id,
suspected_telegram_id=user_id,
)
user = await get_user_by_telegram_id(db, user_id)
# Fallback: try user_telegram_id from metadata if primary lookup failed
if not user:
tg_id_raw = metadata.get('user_telegram_id')
if tg_id_raw is None:
tg_id_raw = metadata.get('userTelegramId')
if tg_id_raw is not None:
try:
tg_id = int(tg_id_raw)
except (TypeError, ValueError):
tg_id = None
if tg_id and tg_id > 0:
user = await get_user_by_telegram_id(db, tg_id)
user = await get_user_by_id(db, user_id)
if not user:
logger.warning(
'Webhook YooKassa : user_id= не найден в БД, пропускаем восстановление платежа',
'Webhook YooKassa: пользователь не найден, пропускаем восстановление платежа',
yookassa_payment_id=yookassa_payment_id,
user_id=user_id,
user_telegram_id=metadata.get('user_telegram_id'),
)
return None
# Use the resolved internal ID for the FK column
user_id = user.id
except Exception as e:
logger.warning(
'Webhook YooKassa : не удалось проверить user_id',
'Webhook YooKassa: не удалось проверить user_id',
yookassa_payment_id=yookassa_payment_id,
user_id=user_id,
e=e,
+47
View File
@@ -686,6 +686,53 @@ class PaymentService(
}
return None
# --- Telegram Stars ---------------------------------------------------
if payment_method == 'telegram_stars':
if not settings.TELEGRAM_STARS_ENABLED:
logger.warning('Telegram Stars is not enabled, cannot create guest payment')
return None
if self.bot is None:
logger.warning('Bot instance required for Stars guest payment')
return None
from aiogram.types import LabeledPrice
rate = settings.get_stars_rate()
if rate <= 0:
logger.error('TELEGRAM_STARS_RATE_RUB is not positive, cannot create Stars invoice')
return None
amount_rubles = amount_kopeks / 100
stars_amount = max(1, round(amount_rubles / rate))
payload = f'guest_purchase_{purchase_token}'
try:
invoice_url = await self.bot.create_invoice_link(
title='Подарочная подписка VPN',
description=f'{description} ({stars_amount} ⭐)',
payload=payload,
provider_token='',
currency='XTR',
prices=[LabeledPrice(label='Подарочная подписка', amount=stars_amount)],
)
logger.info(
'Created Stars invoice for guest purchase',
stars_amount=stars_amount,
purchase_token_prefix=purchase_token[:5],
)
return {
'payment_url': invoice_url,
'payment_id': f'stars_{purchase_token[:12]}',
'provider': 'telegram_stars',
}
except Exception as stars_error:
logger.error('Error creating Stars invoice for guest payment', error=stars_error)
return None
# --- Unsupported provider ---------------------------------------------
logger.warning(
'Guest payment requested for unsupported provider',
+427
View File
@@ -0,0 +1,427 @@
from __future__ import annotations
import dataclasses
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
import structlog
from app.config import CLASSIC_PERIOD_PRICES, PERIOD_PRICES, settings
from app.database.crud.server_squad import get_server_squads_by_uuids
from app.utils.pricing_utils import calculate_months_from_days
from app.utils.promo_offer import get_user_active_promo_discount_percent
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Subscription, User
logger = structlog.get_logger(__name__)
@dataclass(frozen=True)
class TariffBreakdown:
"""Typed breakdown for tariff mode pricing."""
tariff_id: int
extra_devices: int
group_discount_pct: int
offer_discount_pct: int
@dataclass(frozen=True)
class ClassicBreakdown:
"""Typed breakdown for classic mode pricing."""
months_in_period: int
servers: list[dict[str, Any]]
servers_individual_prices: list[int]
server_ids: list[int]
base_traffic_gb: int
purchased_traffic_gb: int
extra_devices: int
# NB: dict[str, int] per-category (period/servers/traffic/devices), unlike TariffBreakdown's single int
group_discount_pct: dict[str, int]
offer_discount_pct: int
@dataclass(frozen=True)
class RenewalPricing:
"""Immutable result of a renewal price calculation."""
base_price: int # kopeks
servers_price: int # kopeks
traffic_price: int # kopeks
devices_price: int # kopeks
promo_group_discount: int # kopeks deducted
promo_offer_discount: int # kopeks deducted
final_total: int # kopeks — amount to charge
period_days: int
is_tariff_mode: bool
breakdown: dict[str, Any] = field(default_factory=dict)
@property
def original_total(self) -> int:
"""Price before all discounts (group + offer)."""
return self.final_total + self.promo_group_discount + self.promo_offer_discount
class PricingEngine:
"""Unified pricing engine for all subscription renewal calculations."""
@staticmethod
def apply_discount(amount_kopeks: int, percent: int) -> int:
"""Apply percentage discount with integer arithmetic.
Clamps percent to [0, 100]. Uses floor division."""
percent = max(0, min(100, percent))
discount = amount_kopeks * percent // 100
return amount_kopeks - discount
@staticmethod
def apply_stacked_discounts(
amount: int,
group_percent: int,
offer_percent: int,
) -> tuple[int, int, int]:
"""Apply promo-group discount, then promo-offer discount sequentially.
Returns (final_amount, group_discount_value, offer_discount_value)."""
after_group = PricingEngine.apply_discount(amount, group_percent)
group_discount_value = amount - after_group
after_offer = PricingEngine.apply_discount(after_group, offer_percent)
offer_discount_value = after_group - after_offer
return after_offer, group_discount_value, offer_discount_value
async def _calculate_servers_price(
self,
country_uuids: list[str],
db: AsyncSession,
*,
promo_group_id: int | None = None,
) -> tuple[int, list[dict]]:
"""Calculate total server price from connected squad UUIDs.
Uses a single batch query instead of N+1 individual queries.
ALWAYS uses real price_kopeks even when server is unavailable
or full. Only orphaned UUIDs (not found in DB) get price=0.
"""
if not country_uuids:
return 0, []
try:
servers = await get_server_squads_by_uuids(db, country_uuids)
except Exception as e: # intentional broad catch: pricing must not crash on DB errors, servers_price=0 is safe (user pays less)
logger.error('Ошибка пакетной загрузки серверов', error=str(e), squad_uuids=country_uuids)
return 0, [{'uuid': uuid, 'id': None, 'price': 0, 'status': 'error'} for uuid in country_uuids]
server_map = {s.squad_uuid: s for s in servers}
total_price = 0
details: list[dict] = []
for uuid in country_uuids:
server = server_map.get(uuid)
if server is None:
logger.error('Сервер не найден в БД', squad_uuid=uuid)
details.append({'uuid': uuid, 'id': None, 'price': 0, 'status': 'not_found'})
continue
price = server.price_kopeks or 0
status = 'available'
if not server.is_available:
status = 'unavailable'
logger.warning(
'Сервер недоступен, используем реальную цену',
squad_uuid=uuid,
price_kopeks=price,
)
elif server.is_full:
status = 'full'
logger.warning(
'Сервер переполнен, используем реальную цену',
squad_uuid=uuid,
price_kopeks=price,
)
elif promo_group_id is not None:
allowed_ids = [pg.id for pg in (server.allowed_promo_groups or [])]
if allowed_ids and promo_group_id not in allowed_ids:
status = 'not_allowed'
logger.warning(
'Сервер недоступен для промогруппы, используем реальную цену',
squad_uuid=uuid,
promo_group_id=promo_group_id,
price_kopeks=price,
)
total_price += price
details.append({'uuid': uuid, 'id': server.id, 'price': price, 'status': status})
return total_price, details
def _calculate_traffic_price(
self,
traffic_limit_gb: int,
purchased_traffic_gb: int,
) -> int:
"""Calculate traffic price, separating base from purchased GB.
Prevents purchased top-ups from inflating the tier lookup."""
total_gb = traffic_limit_gb or 0
purchased_gb = purchased_traffic_gb or 0
base_gb = max(0, total_gb - purchased_gb)
base_price = settings.get_traffic_price(base_gb) if base_gb > 0 else 0
purchased_price = settings.get_traffic_price(purchased_gb) if purchased_gb > 0 else 0
return base_price + purchased_price
# ------------------------------------------------------------------
# Main public method
# ------------------------------------------------------------------
async def calculate_renewal_price(
self,
db: AsyncSession,
subscription: Subscription,
period_days: int,
*,
user: User | None = None,
) -> RenewalPricing:
"""Calculate renewal price for a subscription.
Routes to tariff mode (subscription has a tariff) or classic mode
(legacy env-based pricing). Stacked discounts (promo-group then
promo-offer) are applied in both modes.
"""
if not isinstance(period_days, int) or period_days <= 0:
raise ValueError(f'Invalid period_days: {period_days}')
if subscription.tariff_id is not None:
if subscription.tariff is None:
logger.error(
'tariff_id set but tariff relationship not loaded, falling back to classic mode',
subscription_id=getattr(subscription, 'id', None),
tariff_id=subscription.tariff_id,
)
else:
return await self._calculate_tariff_mode(db, subscription, period_days, user=user)
return await self._calculate_classic_mode(db, subscription, period_days, user=user)
# ------------------------------------------------------------------
# Tariff mode
# ------------------------------------------------------------------
async def _calculate_tariff_mode(
self,
db: AsyncSession,
subscription: Subscription,
period_days: int,
*,
user: User | None = None,
) -> RenewalPricing:
"""Price calculation when subscription is linked to a Tariff."""
tariff = subscription.tariff
period_prices: dict = tariff.period_prices or {}
base_price = int(period_prices.get(str(period_days), 0) or 0)
# Extra devices above the tariff's included limit
device_price_per_unit = (
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
)
extra_devices = max(0, (subscription.device_limit or 0) - (tariff.device_limit or 0))
devices_price = extra_devices * device_price_per_unit
subtotal = base_price + devices_price
# Resolve discounts
group_pct = 0
if user and getattr(user, 'promo_group', None) is not None:
group_pct = user.promo_group.get_discount_percent('period', period_days)
offer_pct = get_user_active_promo_discount_percent(user) if user else 0
final_total, group_discount, offer_discount = self.apply_stacked_discounts(
subtotal,
group_pct,
offer_pct,
)
breakdown = dataclasses.asdict(
TariffBreakdown(
tariff_id=tariff.id,
extra_devices=extra_devices,
group_discount_pct=group_pct,
offer_discount_pct=offer_pct,
)
)
if final_total < 0:
logger.warning(
'Negative final_total in tariff mode, clamping to 0',
final_total=final_total,
subtotal=subtotal,
group_pct=group_pct,
offer_pct=offer_pct,
)
return RenewalPricing(
base_price=base_price,
servers_price=0,
traffic_price=0,
devices_price=devices_price,
promo_group_discount=group_discount,
promo_offer_discount=offer_discount,
final_total=max(0, final_total),
period_days=period_days,
is_tariff_mode=True,
breakdown=breakdown,
)
# ------------------------------------------------------------------
# Classic mode
# ------------------------------------------------------------------
async def _calculate_classic_mode(
self,
db: AsyncSession,
subscription: Subscription,
period_days: int,
*,
user: User | None = None,
) -> RenewalPricing:
"""Price calculation for legacy (non-tariff) subscriptions.
Uses CLASSIC_PERIOD_PRICES from settings, falling back to the
global PERIOD_PRICES dict during migration.
Per-category discounts (period, servers, traffic, devices) are
applied separately to each component. Servers, traffic, and
devices are monthly prices multiplied by months_in_period.
"""
months = calculate_months_from_days(period_days)
# --- Base period price (already includes full period) ---
base_price_original = CLASSIC_PERIOD_PRICES.get(period_days)
if base_price_original is None:
base_price_original = PERIOD_PRICES.get(period_days, 0)
if base_price_original > 0:
logger.warning(
'CLASSIC_PERIOD_PRICES miss, falling back to PERIOD_PRICES — verify price is not from tariff regime',
period_days=period_days,
fallback_price_kopeks=base_price_original,
)
# --- Per-category discount percents ---
period_pct = 0
servers_pct = 0
traffic_pct = 0
devices_pct = 0
promo_group = None
if user and getattr(user, 'promo_group', None) is not None:
promo_group = user.promo_group
period_pct = promo_group.get_discount_percent('period', period_days)
servers_pct = promo_group.get_discount_percent('servers', period_days)
traffic_pct = promo_group.get_discount_percent('traffic', period_days)
devices_pct = promo_group.get_discount_percent('devices', period_days)
offer_pct = get_user_active_promo_discount_percent(user) if user else 0
# --- Base price with period discount ---
base_price = self.apply_discount(base_price_original, period_pct)
# --- Servers (monthly × months, with servers discount) ---
connected_squads: list[str] = subscription.connected_squads or []
promo_group_id = getattr(user, 'promo_group_id', None) if user else None
servers_price_per_month, server_details = await self._calculate_servers_price(
connected_squads,
db,
promo_group_id=promo_group_id,
)
discounted_servers_per_month = self.apply_discount(servers_price_per_month, servers_pct)
servers_price = discounted_servers_per_month * months
# --- Traffic (monthly × months, with traffic discount) ---
if settings.is_traffic_fixed():
traffic_limit_gb = settings.get_fixed_traffic_limit()
purchased_traffic_gb = 0
else:
traffic_limit_gb = (
subscription.traffic_limit_gb
if subscription.traffic_limit_gb is not None
else settings.DEFAULT_TRAFFIC_LIMIT_GB
)
purchased_traffic_gb = subscription.purchased_traffic_gb or 0
traffic_price_per_month = self._calculate_traffic_price(traffic_limit_gb, purchased_traffic_gb)
discounted_traffic_per_month = self.apply_discount(traffic_price_per_month, traffic_pct)
traffic_price = discounted_traffic_per_month * months
# --- Devices (monthly × months, with devices discount) ---
default_device_limit = settings.DEFAULT_DEVICE_LIMIT
device_price_per_unit = settings.PRICE_PER_DEVICE
extra_devices = max(0, (subscription.device_limit or 0) - default_device_limit)
devices_price_per_month = extra_devices * device_price_per_unit
discounted_devices_per_month = self.apply_discount(devices_price_per_month, devices_pct)
devices_price = discounted_devices_per_month * months
# --- Subtotal (category discounts already applied) ---
subtotal = base_price + servers_price + traffic_price + devices_price
# --- Promo offer discount on entire subtotal ---
after_offer = self.apply_discount(subtotal, offer_pct)
promo_offer_discount = subtotal - after_offer
final_total = after_offer
# Total group discount = sum of per-category discounts
base_group_discount = base_price_original - base_price
servers_group_discount = (servers_price_per_month - discounted_servers_per_month) * months
traffic_group_discount = (traffic_price_per_month - discounted_traffic_per_month) * months
devices_group_discount = (devices_price_per_month - discounted_devices_per_month) * months
total_group_discount = (
base_group_discount + servers_group_discount + traffic_group_discount + devices_group_discount
)
valid_servers = [d for d in server_details if d.get('id') is not None]
breakdown = dataclasses.asdict(
ClassicBreakdown(
months_in_period=months,
servers=server_details,
servers_individual_prices=[d['price'] * months for d in valid_servers],
server_ids=[d['id'] for d in valid_servers],
base_traffic_gb=max(0, traffic_limit_gb - purchased_traffic_gb),
purchased_traffic_gb=purchased_traffic_gb,
extra_devices=extra_devices,
group_discount_pct={
'period': period_pct,
'servers': servers_pct,
'traffic': traffic_pct,
'devices': devices_pct,
},
offer_discount_pct=offer_pct,
)
)
if final_total < 0:
logger.warning(
'Negative final_total in classic mode, clamping to 0',
final_total=final_total,
subtotal=subtotal,
offer_pct=offer_pct,
)
return RenewalPricing(
base_price=base_price,
servers_price=servers_price,
traffic_price=traffic_price,
devices_price=devices_price,
promo_group_discount=total_group_discount,
promo_offer_discount=promo_offer_discount,
final_total=max(0, final_total),
period_days=period_days,
is_tariff_mode=False,
breakdown=breakdown,
)
# Module-level singleton — use this instead of PricingEngine()
pricing_engine = PricingEngine()
+5 -2
View File
@@ -224,12 +224,15 @@ async def _process_single_subscription(
autopay_period = 30
try:
renewal_cost = await subscription_service.calculate_renewal_price(
from app.services.pricing_engine import pricing_engine
pricing = await pricing_engine.calculate_renewal_price(
db,
subscription,
autopay_period,
db,
user=user,
)
renewal_cost = pricing.final_total
except Exception as e:
logger.error(
'Ошибка расчёта стоимости для рекуррентного платежа',
+16 -8
View File
@@ -1796,6 +1796,8 @@ class RemnaWaveService:
if panel_status == 'ACTIVE' and end_date_utc > current_time:
new_status = SubscriptionStatus.ACTIVE.value
elif panel_status == 'LIMITED':
new_status = SubscriptionStatus.LIMITED.value
elif panel_status == 'DISABLED':
new_status = SubscriptionStatus.DISABLED.value
elif end_date_utc <= current_time:
@@ -2387,7 +2389,8 @@ class RemnaWaveService:
async def force_cleanup_user_data(self, db: AsyncSession, user: User) -> bool:
"""
ОПАСНАЯ ФУНКЦИЯ: Полностью сбрасывает все данные пользователя включая баланс!
ОПАСНАЯ ФУНКЦИЯ: Полностью сбрасывает данные подписки пользователя.
Баланс и has_had_paid_subscription СОХРАНЯЮТСЯ (оплаченные средства).
Используйте только для полной очистки пользователя.
"""
try:
@@ -2422,7 +2425,6 @@ class RemnaWaveService:
from sqlalchemy import delete
from app.database.models import (
PromoCodeUse,
ReferralEarning,
SubscriptionServer,
SubscriptionStatus,
@@ -2444,17 +2446,20 @@ class RemnaWaveService:
await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user.id))
logger.info('🗑️ Удалены реферальные доходы для', user_id_display=user_id_display)
await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id))
logger.info('🗑️ Удалены использования промокодов для', user_id_display=user_id_display)
# PromoCodeUse НЕ удаляем — история промокодов постоянна,
# иначе пользователь может повторно активировать промокоды
except Exception as records_error:
logger.error('❌ Ошибка удаления связанных записей', records_error=records_error)
try:
user.balance_kopeks = 0
if user.balance_kopeks > 0:
logger.warning(
'⚠️ force_cleanup: СОХРАНЯЕМ баланс пользователя (оплаченные средства)',
user_id_display=user_id_display,
balance_kopeks=user.balance_kopeks,
)
user.remnawave_uuid = None
user.has_had_paid_subscription = False
user.used_promocodes = 0
user.updated_at = self._now_utc()
if user.subscription:
@@ -2524,7 +2529,10 @@ class RemnaWaveService:
stats['checked'] += 1
user = subscription.user
if subscription.status == SubscriptionStatus.DISABLED.value:
if subscription.status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.LIMITED.value,
):
continue
if user.telegram_id not in panel_telegram_ids:
+9 -6
View File
@@ -530,7 +530,7 @@ class RemnaWaveWebhookService:
) -> None:
if subscription:
self._stamp_webhook_update(subscription)
if subscription.status == SubscriptionStatus.DISABLED.value:
if subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.LIMITED.value):
await reactivate_subscription(db, subscription)
logger.info(
'Webhook: subscription re-enabled for user', subscription_id=subscription.id, user_id=user.id
@@ -545,8 +545,11 @@ class RemnaWaveWebhookService:
) -> None:
if subscription:
self._stamp_webhook_update(subscription)
if subscription.status == SubscriptionStatus.ACTIVE.value:
await deactivate_subscription(db, subscription)
if subscription.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value):
subscription.status = SubscriptionStatus.LIMITED.value
subscription.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(subscription)
logger.info(
'Webhook: subscription limited (traffic) for user', subscription_id=subscription.id, user_id=user.id
)
@@ -561,8 +564,8 @@ class RemnaWaveWebhookService:
if subscription:
self._stamp_webhook_update(subscription)
await update_subscription_usage(db, subscription, 0.0)
# Re-enable if was disabled due to traffic limit
if subscription.status == SubscriptionStatus.DISABLED.value:
# Re-enable if was disabled/limited due to traffic limit
if subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.LIMITED.value):
await reactivate_subscription(db, subscription)
logger.info(
'Webhook: traffic reset for subscription , user', subscription_id=subscription.id, user_id=user.id
@@ -721,7 +724,7 @@ class RemnaWaveWebhookService:
subscription.subscription_url = None
subscription.subscription_crypto_link = None
subscription.remnawave_short_uuid = None
subscription.connected_squads = None
subscription.connected_squads = []
subscription.updated_at = datetime.now(UTC)
# Remove SubscriptionServer link rows
+269 -110
View File
@@ -18,6 +18,7 @@ from app.database.crud.user import get_user_by_id, subtract_user_balance
from app.database.models import Subscription, SubscriptionStatus, TransactionType, User
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.pricing_engine import PricingEngine, pricing_engine
from app.services.subscription_checkout_service import clear_subscription_checkout_draft
from app.services.subscription_purchase_service import (
MiniAppSubscriptionPurchaseService,
@@ -142,55 +143,7 @@ def _safe_int(value: object | None, default: int = 0) -> int:
def _apply_promo_discount_for_tariff(price: int, discount_percent: int) -> int:
"""Применяет скидку промогруппы к цене тарифа."""
if discount_percent <= 0:
return price
discount = int(price * discount_percent / 100)
return max(0, price - discount)
async def _get_tariff_price_for_period(
db: AsyncSession,
user: User,
tariff_id: int,
period_days: int,
) -> tuple[int, int] | None:
"""Получает базовую цену тарифа и процент скидки (без применения).
Returns:
(base_price, discount_percent) или None если тариф/период недоступен.
Скидка НЕ применяется вызывающий код должен сначала добавить доп. устройства,
затем применить скидку к полной сумме (как в cabinet).
"""
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
logger.warning(
'🔁 Автопокупка: тариф недоступен для пользователя',
tariff_id=tariff_id,
format_user_id=_format_user_id(user),
)
return None
prices = tariff.period_prices or {}
base_price = prices.get(str(period_days))
if base_price is None:
logger.warning(
'🔁 Автопокупка: период дней недоступен для тарифа', period_days=period_days, tariff_id=tariff_id
)
return None
# Возвращаем только promo_group скидку.
# Promo_offer скидку вызывающий код должен применить отдельно (последовательно, как в cabinet).
discount_percent = 0
if hasattr(user, 'get_promo_discount'):
discount_percent = user.get_promo_discount('period', period_days)
else:
promo_group = getattr(user, 'promo_group', None)
if promo_group and hasattr(promo_group, 'get_discount_percent'):
discount_percent = promo_group.get_discount_percent('period', period_days)
return (int(base_price), discount_percent)
return PricingEngine.apply_discount(price, discount_percent)
async def _prepare_auto_extend_context(
@@ -229,56 +182,28 @@ async def _prepare_auto_extend_context(
)
return None
# Если в корзине есть tariff_id - пересчитываем цену по актуальному тарифу
# Fresh pricing via unified PricingEngine (no stale cart prices)
tariff_id = cart_data.get('tariff_id')
if tariff_id:
tariff_id = _safe_int(tariff_id)
tariff_result = await _get_tariff_price_for_period(db, user, tariff_id, period_days)
if tariff_result is None:
# Тариф недоступен или период отсутствует - используем сохранённую цену как fallback
price_kopeks = _safe_int(
cart_data.get('total_price') or cart_data.get('price') or cart_data.get('final_price'),
)
logger.warning(
'🔁 Автопокупка: не удалось пересчитать цену тарифа , используем сохранённую',
tariff_id=tariff_id,
price_kopeks=price_kopeks,
)
else:
base_price, discount_percent = tariff_result
price_kopeks = base_price
# Добавляем стоимость докупленных устройств ДО применения скидки (как в cabinet)
if subscription.tariff_id == tariff_id:
from app.database.crud.tariff import get_tariff_by_id as _get_tariff
from app.services.pricing_engine import pricing_engine as _pricing_engine
_tariff = await _get_tariff(db, tariff_id)
if _tariff:
extra_devices = max(0, (subscription.device_limit or 0) - (_tariff.device_limit or 0))
if extra_devices > 0:
from app.utils.pricing_utils import calculate_months_from_days
device_price_per_month = (
_tariff.device_price_kopeks
if _tariff.device_price_kopeks is not None
else settings.PRICE_PER_DEVICE
)
months = calculate_months_from_days(period_days)
price_kopeks += extra_devices * device_price_per_month * months
# Применяем promo_group скидку к полной сумме (база + доп. устройства)
price_kopeks = _apply_promo_discount_for_tariff(price_kopeks, discount_percent)
# Применяем promo_offer скидку отдельно (последовательно, как в cabinet)
from app.utils.promo_offer import get_user_active_promo_discount_percent
promo_offer_percent = get_user_active_promo_discount_percent(user)
if promo_offer_percent > 0:
price_kopeks = _apply_promo_discount_for_tariff(price_kopeks, promo_offer_percent)
else:
price_kopeks = _safe_int(
cart_data.get('total_price') or cart_data.get('price') or cart_data.get('final_price'),
try:
pricing = await _pricing_engine.calculate_renewal_price(
db,
subscription,
period_days,
user=user,
)
price_kopeks = pricing.final_total
except Exception as e:
logger.error(
'Автопокупка: ошибка PricingEngine, пропускаем автопродление',
format_user_id=_format_user_id(user),
error=str(e),
)
return None
if price_kopeks <= 0:
logger.warning(
@@ -398,6 +323,15 @@ async def _auto_extend_subscription(
)
return False
# Save promo offer state before charge so we can restore on failure
saved_promo_percent = (
int(getattr(user, 'promo_offer_discount_percent', 0) or 0) if prepared.consume_promo_offer else 0
)
saved_promo_source = getattr(user, 'promo_offer_discount_source', None) if prepared.consume_promo_offer else None
saved_promo_expires = (
getattr(user, 'promo_offer_discount_expires_at', None) if prepared.consume_promo_offer else None
)
try:
deducted = await subtract_user_balance(
db,
@@ -462,8 +396,44 @@ async def _auto_extend_subscription(
error=error,
exc_info=True,
)
# НОВОЕ: Откатываем изменения при ошибке
await db.rollback()
# Compensating refund: balance was already committed by subtract_user_balance
try:
from app.database.crud.user import add_user_balance
await add_user_balance(
db,
user,
prepared.price_kopeks,
'Возврат: ошибка автопродления подписки',
create_transaction=True,
transaction_type=TransactionType.REFUND,
)
# Restore consumed promo offer fields
if prepared.consume_promo_offer and saved_promo_percent > 0:
user.promo_offer_discount_percent = saved_promo_percent
user.promo_offer_discount_source = saved_promo_source
user.promo_offer_discount_expires_at = saved_promo_expires
await db.commit()
logger.info(
'💰 Автопокупка: восстановлен промо-оффер после ошибки продления',
format_user_id=_format_user_id(user),
restored_percent=saved_promo_percent,
)
logger.info(
'💰 Автопокупка: возврат средств после ошибки продления',
format_user_id=_format_user_id(user),
refund_kopeks=prepared.price_kopeks,
)
except Exception as refund_error:
logger.critical(
'CRITICAL: Автопокупка: не удалось вернуть средства после ошибки продления',
format_user_id=_format_user_id(user),
price_kopeks=prepared.price_kopeks,
refund_error=refund_error,
)
return False
transaction = None
@@ -673,13 +643,10 @@ async def _auto_purchase_tariff(
if existing_subscription and existing_subscription.tariff_id == tariff_id:
extra_devices = max(0, (existing_subscription.device_limit or 0) - (tariff.device_limit or 0))
if extra_devices > 0:
from app.utils.pricing_utils import calculate_months_from_days
device_price_per_month = (
device_price_per_unit = (
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
)
months = calculate_months_from_days(period_days)
extra_devices_cost = extra_devices * device_price_per_month * months
extra_devices_cost = extra_devices * device_price_per_unit
final_price += extra_devices_cost
# Пересчитываем скидку из актуальных данных пользователя (не из stale корзины)
@@ -706,6 +673,12 @@ async def _auto_purchase_tariff(
)
return False
# Save promo offer state before deduction (for restore on failure)
consume_promo = promo_offer_percent > 0
saved_promo_percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0) if consume_promo else 0
saved_promo_source = getattr(user, 'promo_offer_discount_source', None) if consume_promo else None
saved_promo_expires = getattr(user, 'promo_offer_discount_expires_at', None) if consume_promo else None
# Списываем баланс
try:
description = f'Покупка тарифа {tariff.name} на {period_days} дней'
@@ -714,7 +687,7 @@ async def _auto_purchase_tariff(
user,
final_price,
description,
consume_promo_offer=promo_offer_percent > 0,
consume_promo_offer=consume_promo,
mark_as_paid_subscription=True,
)
if not success:
@@ -779,6 +752,36 @@ async def _auto_purchase_tariff(
exc_info=True,
)
await db.rollback()
# Compensating refund: balance was already committed by subtract_user_balance
try:
from app.database.crud.user import add_user_balance
await add_user_balance(
db,
user,
final_price,
'Возврат: ошибка автопокупки тарифа',
create_transaction=True,
transaction_type=TransactionType.REFUND,
)
# Restore promo offer if consumed
if consume_promo and saved_promo_percent > 0:
user.promo_offer_discount_percent = saved_promo_percent
user.promo_offer_discount_source = saved_promo_source
user.promo_offer_discount_expires_at = saved_promo_expires
await db.commit()
logger.info(
'💰 Автопокупка тарифа: возврат средств после ошибки создания подписки',
format_user_id=_format_user_id(user),
refund_kopeks=final_price,
)
except Exception as refund_error:
logger.critical(
'CRITICAL: Автопокупка тарифа: не удалось вернуть средства после ошибки создания подписки',
format_user_id=_format_user_id(user),
price_kopeks=final_price,
refund_error=refund_error,
)
return False
# Создаём транзакцию
@@ -1023,9 +1026,20 @@ async def _auto_purchase_daily_tariff(
# Обновляем существующую подписку на суточный тариф
# Суточность определяется через tariff.is_daily, поэтому достаточно установить tariff_id
was_trial_conversion = existing_subscription.is_trial # Сохраняем до изменения
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
from app.database.crud.tariff import get_tariff_by_id as _get_old_tariff
old_tariff = (
await _get_old_tariff(db, existing_subscription.tariff_id) if existing_subscription.tariff_id else None
)
existing_subscription.tariff_id = tariff.id
existing_subscription.traffic_limit_gb = tariff.traffic_limit_gb
existing_subscription.device_limit = tariff.device_limit
existing_subscription.device_limit = calc_device_limit_on_tariff_switch(
current_device_limit=existing_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=getattr(tariff, 'max_device_limit', None),
)
existing_subscription.connected_squads = squads
existing_subscription.status = 'active'
existing_subscription.is_trial = False
@@ -1060,6 +1074,30 @@ async def _auto_purchase_daily_tariff(
exc_info=True,
)
await db.rollback()
# Compensating refund: balance was already committed by subtract_user_balance
try:
from app.database.crud.user import add_user_balance
await add_user_balance(
db,
user,
daily_price,
'Возврат: ошибка автопокупки суточного тарифа',
create_transaction=True,
transaction_type=TransactionType.REFUND,
)
logger.info(
'💰 Автопокупка суточного тарифа: возврат средств после ошибки создания подписки',
format_user_id=_format_user_id(user),
refund_kopeks=daily_price,
)
except Exception as refund_error:
logger.critical(
'CRITICAL: Автопокупка суточного тарифа: не удалось вернуть средства',
format_user_id=_format_user_id(user),
price_kopeks=daily_price,
refund_error=refund_error,
)
return False
# Создаём транзакцию
@@ -1244,7 +1282,7 @@ async def _auto_add_devices(
await user_cart_service.delete_user_cart(user.id)
return False
if subscription.status not in ('active', 'trial', 'disabled', 'ACTIVE', 'TRIAL', 'DISABLED'):
if subscription.status not in ('active', 'trial', 'disabled', 'limited', 'ACTIVE', 'TRIAL', 'DISABLED', 'LIMITED'):
logger.warning(
'🔁 Автопокупка устройств: подписка пользователя не активна (status=)',
format_user_id=_format_user_id(user),
@@ -1361,7 +1399,7 @@ async def _auto_add_devices(
await db.rollback()
return False
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
from app.database.crud.subscription import reactivate_subscription
await reactivate_subscription(db, subscription)
@@ -1370,6 +1408,9 @@ async def _auto_add_devices(
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
except Exception as error:
logger.warning(
'⚠️ Автопокупка устройств: не удалось обновить Remnawave для пользователя',
@@ -1510,7 +1551,7 @@ async def _auto_add_traffic(
await user_cart_service.delete_user_cart(user.id)
return False
if subscription.status not in ('active', 'trial', 'disabled', 'ACTIVE', 'TRIAL', 'DISABLED'):
if subscription.status not in ('active', 'trial', 'disabled', 'limited', 'ACTIVE', 'TRIAL', 'DISABLED', 'LIMITED'):
logger.warning(
'🔁 Автопокупка трафика: подписка пользователя не активна (status=)',
format_user_id=_format_user_id(user),
@@ -1571,9 +1612,33 @@ async def _auto_add_traffic(
exc_info=True,
)
await db.rollback()
# Compensating refund: balance was already committed by subtract_user_balance
try:
from app.database.crud.user import add_user_balance
await add_user_balance(
db,
user,
price_kopeks,
'Возврат: ошибка автопокупки трафика',
create_transaction=True,
transaction_type=TransactionType.REFUND,
)
logger.info(
'💰 Автопокупка трафика: возврат средств после ошибки добавления трафика',
format_user_id=_format_user_id(user),
refund_kopeks=price_kopeks,
)
except Exception as refund_error:
logger.critical(
'CRITICAL: Автопокупка трафика: не удалось вернуть средства',
format_user_id=_format_user_id(user),
price_kopeks=price_kopeks,
refund_error=refund_error,
)
return False
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
from app.database.crud.subscription import reactivate_subscription
await reactivate_subscription(db, subscription)
@@ -1582,6 +1647,9 @@ async def _auto_add_traffic(
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
except Exception as error:
logger.warning(
'⚠️ Автопокупка трафика: не удалось обновить Remnawave для пользователя',
@@ -1733,15 +1801,16 @@ async def try_auto_extend_expired_after_topup(
else:
period_days = 30
# Calculate renewal price
# Calculate renewal price via PricingEngine
subscription_service = SubscriptionService()
try:
renewal_cost = await subscription_service.calculate_renewal_price(
pricing = await pricing_engine.calculate_renewal_price(
db,
subscription,
period_days,
db,
user=user,
)
renewal_cost = pricing.final_total
except Exception as error:
logger.error(
'❌ Автопродление expired: ошибка расчёта стоимости',
@@ -1751,6 +1820,15 @@ async def try_auto_extend_expired_after_topup(
)
return False
logger.info(
'Расчёт цены автопродления (PricingEngine)',
user_id=getattr(user, 'id', None),
period_days=period_days,
final_total=pricing.final_total,
is_tariff_mode=pricing.is_tariff_mode,
breakdown=pricing.breakdown,
)
if renewal_cost <= 0:
logger.warning(
'❌ Автопродление expired: некорректная стоимость',
@@ -1797,6 +1875,11 @@ async def try_auto_extend_expired_after_topup(
consume_promo_offer = get_user_active_promo_discount_percent(user) > 0
# Save promo offer state before deduction (for restore on failure)
saved_promo_percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0) if consume_promo_offer else 0
saved_promo_source = getattr(user, 'promo_offer_discount_source', None) if consume_promo_offer else None
saved_promo_expires = getattr(user, 'promo_offer_discount_expires_at', None) if consume_promo_offer else None
# Deduct balance
description = f'Автопродление истёкшей подписки на {period_days} дней'
try:
@@ -1849,6 +1932,36 @@ async def try_auto_extend_expired_after_topup(
exc_info=True,
)
await db.rollback()
# Compensating refund: balance was already committed by subtract_user_balance
try:
from app.database.crud.user import add_user_balance
await add_user_balance(
db,
user,
renewal_cost,
'Возврат: ошибка автопродления истёкшей подписки',
create_transaction=True,
transaction_type=TransactionType.REFUND,
)
# Restore promo offer if consumed
if consume_promo_offer and saved_promo_percent > 0:
user.promo_offer_discount_percent = saved_promo_percent
user.promo_offer_discount_source = saved_promo_source
user.promo_offer_discount_expires_at = saved_promo_expires
await db.commit()
logger.info(
'💰 Автопродление expired: возврат средств после ошибки продления',
format_user_id=_format_user_id(user),
refund_kopeks=renewal_cost,
)
except Exception as refund_error:
logger.critical(
'CRITICAL: Автопродление expired: не удалось вернуть средства',
format_user_id=_format_user_id(user),
price_kopeks=renewal_cost,
refund_error=refund_error,
)
return False
# Create transaction record
@@ -2010,8 +2123,12 @@ async def try_resume_disabled_daily_after_topup(
if subscription is None:
return False
# Only handle DISABLED (or EXPIRED) daily tariff subscriptions
if subscription.status not in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
# Only handle DISABLED/LIMITED (or EXPIRED) daily tariff subscriptions
if subscription.status not in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
):
return False
if not getattr(subscription, 'is_daily_tariff', False):
return False
@@ -2103,6 +2220,30 @@ async def try_resume_disabled_daily_after_topup(
exc_info=True,
)
await db.rollback()
# Compensating refund: balance was already committed by subtract_user_balance
try:
from app.database.crud.user import add_user_balance
await add_user_balance(
db,
user,
daily_price,
'Возврат: ошибка авто-возобновления суточной подписки',
create_transaction=True,
transaction_type=TransactionType.REFUND,
)
logger.info(
'💰 Авто-возобновление daily: возврат средств после ошибки активации',
format_user_id=_format_user_id(user),
refund_kopeks=daily_price,
)
except Exception as refund_error:
logger.critical(
'CRITICAL: Авто-возобновление daily: не удалось вернуть средства',
format_user_id=_format_user_id(user),
price_kopeks=daily_price,
refund_error=refund_error,
)
return False
logger.info(
@@ -2277,6 +2418,24 @@ async def auto_purchase_saved_cart_after_topup(
logger.info('🔁 Автопокупка: обнаружена сохранённая корзина у пользователя', format_user_id=_format_user_id(user))
# Защита от автопокупки на DISABLED подписке — пользователь отключён в панели,
# сохранённая корзина устарела. Списание баланса необратимо, а Remnawave-обновление
# провалится → баланс потерян навсегда.
# Суточные тарифы тоже блокируем: try_resume_disabled_daily_after_topup уже отработал
# выше по цепочке (common.py), и если он не возобновил — причина сохраняется.
from app.database.crud.subscription import get_subscription_by_user_id as _get_sub
_existing_sub = await _get_sub(db, user.id)
if _existing_sub and _existing_sub.status == SubscriptionStatus.DISABLED.value:
logger.warning(
'🔁 Автопокупка: пропускаем — подписка DISABLED, корзина устарела',
format_user_id=_format_user_id(user),
subscription_status=_existing_sub.status,
)
await user_cart_service.delete_user_cart(user.id)
await clear_subscription_checkout_draft(user.id)
return False
cart_mode = cart_data.get('cart_mode') or cart_data.get('mode')
# Защита от race condition: если подписка была куплена/продлена в последние 60 секунд,
+7 -19
View File
@@ -27,6 +27,7 @@ from app.database.models import ServerSquad, Subscription, SubscriptionStatus, T
from app.localization.texts import get_texts
from app.services.subscription_service import SubscriptionService
from app.utils.pricing_utils import (
apply_percentage_discount,
calculate_months_from_days,
format_period_description,
validate_pricing_calculation,
@@ -266,21 +267,8 @@ class PurchaseBalanceError(Exception):
super().__init__(message)
def _apply_percentage_discount(amount: int, percent: int) -> tuple[int, int]:
if amount <= 0 or percent <= 0:
return amount, 0
clamped = max(0, min(100, percent))
discount_value = amount * clamped // 100
discounted = amount - discount_value
if discount_value >= 100 and discounted % 100:
discounted += 100 - (discounted % 100)
discounted = min(discounted, amount)
discount_value = amount - discounted
return discounted, discount_value
def _apply_discount_to_monthly_component(amount_per_month: int, percent: int, months: int) -> dict[str, int]:
discounted_per_month, discount_per_month = _apply_percentage_discount(amount_per_month, percent)
discounted_per_month, discount_per_month = apply_percentage_discount(amount_per_month, percent)
return {
'original_per_month': amount_per_month,
'discounted_per_month': discounted_per_month,
@@ -299,7 +287,7 @@ def _apply_promo_offer_discount(user: User | None, amount: int) -> tuple[int, in
percent = _get_promo_offer_discount_percent(user)
if amount <= 0 or percent <= 0:
return amount, 0, 0
discounted, discount_value = _apply_percentage_discount(amount, percent)
discounted, discount_value = apply_percentage_discount(amount, percent)
return discounted, discount_value, percent
@@ -309,7 +297,7 @@ def _build_server_option(
texts,
) -> PurchaseServerOption:
base_per_month = int(getattr(server, 'price_kopeks', 0) or 0)
discounted_per_month, _ = _apply_percentage_discount(base_per_month, discount_percent)
discounted_per_month, _ = apply_percentage_discount(base_per_month, discount_percent)
return PurchaseServerOption(
uuid=server.squad_uuid,
name=getattr(server, 'display_name', server.squad_uuid) or server.squad_uuid,
@@ -393,7 +381,7 @@ class MiniAppSubscriptionPurchaseService:
base_price_original = PERIOD_PRICES.get(period_days, 0)
period_discount_percent = user.get_promo_discount('period', period_days)
base_price, base_discount_total = _apply_percentage_discount(base_price_original, period_discount_percent)
base_price, base_discount_total = apply_percentage_discount(base_price_original, period_discount_percent)
base_price_label = texts.format_price(base_price)
base_price_original_label = (
texts.format_price(base_price_original)
@@ -526,7 +514,7 @@ class MiniAppSubscriptionPurchaseService:
for package in packages:
value = int(package.get('gb') or 0)
price_per_month = int(package.get('price') or 0)
discounted_per_month, discount_value = _apply_percentage_discount(price_per_month, discount_percent)
discounted_per_month, discount_value = apply_percentage_discount(price_per_month, discount_percent)
label = texts.format_traffic(value or 0)
options.append(
PurchaseTrafficOption(
@@ -600,7 +588,7 @@ class MiniAppSubscriptionPurchaseService:
) -> PurchaseDevicesConfig:
discount_percent = user.get_promo_discount('devices', period_days)
unit_price = settings.PRICE_PER_DEVICE
discounted_unit_price, unit_discount_value = _apply_percentage_discount(unit_price, discount_percent)
discounted_unit_price, unit_discount_value = apply_percentage_discount(unit_price, discount_percent)
price_label = texts.format_price(discounted_unit_price)
original_label = (
texts.format_price(unit_price) if unit_discount_value and unit_price != discounted_unit_price else None
+147 -245
View File
@@ -4,7 +4,7 @@ import base64
import json
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
@@ -13,24 +13,17 @@ from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.server_squad import get_server_ids_by_uuids, get_server_squads_by_uuids
from app.database.crud.subscription import (
add_subscription_servers,
calculate_subscription_total_cost,
extend_subscription,
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import PaymentMethod, Subscription, Transaction, TransactionType, User
from app.services.admin_notification_service import AdminNotificationService
from app.services.pricing_engine import RenewalPricing
from app.services.remnawave_service import RemnaWaveConfigurationError
from app.services.subscription_service import SubscriptionService
from app.utils.pricing_utils import (
apply_percentage_discount,
calculate_months_from_days,
format_period_description,
validate_pricing_calculation,
)
logger = structlog.get_logger(__name__)
@@ -77,19 +70,67 @@ class SubscriptionRenewalPricing:
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> SubscriptionRenewalPricing:
"""Deserialize from dict. Supports both legacy SubscriptionRenewalPricing
and new RenewalPricing (from PricingEngine) schemas."""
breakdown = payload.get('breakdown') or {}
period_days = int(payload.get('period_days', 0) or 0)
final_total = int(payload.get('final_total', 0) or 0)
# RenewalPricing uses 'promo_offer_discount', legacy uses 'promo_discount_value'
promo_discount_value = int(
payload.get('promo_discount_value', 0) or payload.get('promo_offer_discount', 0) or 0
)
# months: legacy has it directly, RenewalPricing needs derivation
months = int(payload.get('months', 0) or 0)
if not months and period_days > 0:
months = max(1, round(period_days / 30))
# base_original_total: legacy has it, RenewalPricing needs reconstruction
base_original_total = int(payload.get('base_original_total', 0) or 0)
if not base_original_total and final_total > 0:
promo_group_discount = int(payload.get('promo_group_discount', 0) or 0)
base_original_total = final_total + promo_group_discount + promo_discount_value
# discounted_total: legacy has it, RenewalPricing = final + offer discount
discounted_total = int(payload.get('discounted_total', 0) or 0)
if not discounted_total:
discounted_total = final_total + promo_discount_value
# per_month
per_month = int(payload.get('per_month', 0) or 0)
if not per_month and months > 0:
per_month = final_total // months
# server_ids: legacy at top level, RenewalPricing in breakdown
server_ids = list(payload.get('server_ids') or breakdown.get('server_ids') or [])
# details: legacy uses 'details', RenewalPricing uses 'breakdown'
details = dict(payload.get('details') or breakdown or {})
# promo_discount_percent: from payload or breakdown
promo_discount_percent = int(
payload.get('promo_discount_percent', 0) or breakdown.get('offer_discount_pct', 0) or 0
)
# overall_discount_percent: derive if not present
overall_discount_percent = int(payload.get('overall_discount_percent', 0) or 0)
if not overall_discount_percent and base_original_total > 0 and base_original_total > final_total:
overall_discount_percent = int(round((base_original_total - final_total) * 100 / base_original_total))
return cls(
period_days=int(payload.get('period_days', 0) or 0),
period_id=str(payload.get('period_id') or build_renewal_period_id(int(payload.get('period_days', 0) or 0))),
months=int(payload.get('months', 0) or 0),
base_original_total=int(payload.get('base_original_total', 0) or 0),
discounted_total=int(payload.get('discounted_total', 0) or 0),
final_total=int(payload.get('final_total', 0) or 0),
promo_discount_value=int(payload.get('promo_discount_value', 0) or 0),
promo_discount_percent=int(payload.get('promo_discount_percent', 0) or 0),
overall_discount_percent=int(payload.get('overall_discount_percent', 0) or 0),
per_month=int(payload.get('per_month', 0) or 0),
server_ids=list(payload.get('server_ids', []) or []),
details=dict(payload.get('details', {}) or {}),
period_days=period_days,
period_id=str(payload.get('period_id') or build_renewal_period_id(period_days)),
months=months,
base_original_total=base_original_total,
discounted_total=discounted_total,
final_total=final_total,
promo_discount_value=promo_discount_value,
promo_discount_percent=promo_discount_percent,
overall_discount_percent=overall_discount_percent,
per_month=per_month,
server_ids=server_ids,
details=details,
)
@@ -309,145 +350,12 @@ async def with_admin_notification_service(
class SubscriptionRenewalService:
"""Shared helpers for subscription renewal pricing and processing."""
async def calculate_pricing(
self,
db: AsyncSession,
user: User,
subscription: Subscription,
period_days: int,
) -> SubscriptionRenewalPricing:
connected_uuids = [str(uuid) for uuid in list(subscription.connected_squads or [])]
server_ids: list[int] = []
if connected_uuids:
server_ids = await get_server_ids_by_uuids(db, connected_uuids)
# Валидация: проверяем доступность серверов для промогруппы пользователя
await self._validate_servers_for_user_promo_group(db, user, connected_uuids)
# В режиме fixed_with_topup при продлении используем фиксированный лимит
purchased_traffic = subscription.purchased_traffic_gb or 0
if settings.is_traffic_fixed():
traffic_limit = settings.get_fixed_traffic_limit()
# Separate base traffic from purchased to avoid wrong tier lookup
# e.g. 25GB base + 100GB purchased = 125GB total → would round up to 250GB tier
elif purchased_traffic > 0:
base_traffic = (subscription.traffic_limit_gb or 0) - purchased_traffic
if base_traffic <= 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,
)
# All traffic is purchased; pass it as sole traffic_limit
# and clear purchased_traffic to avoid double-counting below
traffic_limit = purchased_traffic
purchased_traffic = 0
else:
traffic_limit = base_traffic
else:
traffic_limit = subscription.traffic_limit_gb
if traffic_limit is None:
traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB
devices_limit = subscription.device_limit
if devices_limit is None:
devices_limit = settings.DEFAULT_DEVICE_LIMIT
total_cost, details = await calculate_subscription_total_cost(
db,
period_days,
int(traffic_limit or 0),
server_ids,
int(devices_limit or 0),
user=user,
)
months = details.get('months_in_period') or calculate_months_from_days(period_days)
# Add purchased traffic cost separately (uses its own tier price, same discount %)
if purchased_traffic > 0 and not settings.is_traffic_fixed():
purchased_price_per_month = settings.get_traffic_price(purchased_traffic)
traffic_discount_pct = details.get('traffic_discount_percent', 0)
purchased_disc_per_month = purchased_price_per_month * traffic_discount_pct // 100
discounted_purchased_per_month = purchased_price_per_month - purchased_disc_per_month
purchased_total = discounted_purchased_per_month * months
purchased_disc_total = purchased_disc_per_month * months
total_cost += purchased_total
details['traffic_price_per_month'] = details.get('traffic_price_per_month', 0) + purchased_price_per_month
details['total_traffic_price'] = details.get('total_traffic_price', 0) + purchased_total
details['traffic_discount_total'] = details.get('traffic_discount_total', 0) + purchased_disc_total
base_original_total = (
details.get('base_price_original', 0)
+ details.get('traffic_price_per_month', 0) * months
+ details.get('servers_price_per_month', 0) * months
+ details.get('devices_price_per_month', 0) * months
)
discounted_total = total_cost
monthly_additions = 0
if months > 0:
monthly_additions = (
details.get('total_servers_price', 0) // months
+ details.get('total_devices_price', 0) // months
+ details.get('total_traffic_price', 0) // months
)
if not validate_pricing_calculation(
details.get('base_price', 0),
monthly_additions,
months,
discounted_total,
):
logger.warning(
'Renewal pricing validation failed for subscription (period)',
subscription_id=subscription.id,
period_days=period_days,
)
from app.utils.promo_offer import get_user_active_promo_discount_percent
promo_percent = get_user_active_promo_discount_percent(user)
final_total = discounted_total
promo_discount_value = 0
if promo_percent > 0 and discounted_total > 0:
final_total, promo_discount_value = apply_percentage_discount(
discounted_total,
promo_percent,
)
overall_discount_value = max(0, base_original_total - final_total)
overall_discount_percent = 0
if base_original_total > 0 and overall_discount_value > 0:
overall_discount_percent = int(round(overall_discount_value * 100 / base_original_total))
per_month = final_total // months if months else final_total
return SubscriptionRenewalPricing(
period_days=period_days,
period_id=build_renewal_period_id(period_days),
months=months,
base_original_total=base_original_total,
discounted_total=discounted_total,
final_total=final_total,
promo_discount_value=promo_discount_value,
promo_discount_percent=promo_percent if promo_discount_value else 0,
overall_discount_percent=overall_discount_percent,
per_month=per_month,
server_ids=list(server_ids),
details=details,
)
async def finalize(
self,
db: AsyncSession,
user: User,
subscription: Subscription,
pricing: SubscriptionRenewalPricing,
pricing: SubscriptionRenewalPricing | RenewalPricing,
*,
charge_balance_amount: int | None = None,
description: str | None = None,
@@ -462,10 +370,19 @@ class SubscriptionRenewalService:
charge_from_balance = final_total
charge_from_balance = max(0, min(charge_from_balance, final_total))
consume_promo_offer = bool(pricing.promo_discount_value)
# Support both SubscriptionRenewalPricing and RenewalPricing
if isinstance(pricing, SubscriptionRenewalPricing):
consume_promo_offer = bool(pricing.promo_discount_value)
else:
consume_promo_offer = bool(pricing.promo_offer_discount)
description_text = description or f'Продление подписки на {period_days} дней'
# Save promo offer state before charge so we can restore on failure
saved_promo_percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0) if consume_promo_offer else 0
saved_promo_source = getattr(user, 'promo_offer_discount_source', None) if consume_promo_offer else None
saved_promo_expires = getattr(user, 'promo_offer_discount_expires_at', None) if consume_promo_offer else None
if charge_from_balance > 0 or consume_promo_offer:
success = await subtract_user_balance(
db,
@@ -479,25 +396,64 @@ class SubscriptionRenewalService:
raise SubscriptionRenewalChargeError('Failed to charge balance')
await db.refresh(user)
subscription_before = subscription
# Lock subscription row to prevent double-extension race
from sqlalchemy import select as sa_select
from app.database.models import Subscription as SubscriptionModel
locked_result = await db.execute(
sa_select(SubscriptionModel)
.where(SubscriptionModel.id == subscription.id)
.with_for_update()
.execution_options(populate_existing=True)
)
subscription_before = locked_result.scalar_one()
old_end_date = subscription_before.end_date
# Determine expired state BEFORE extend_subscription mutates the object
now = datetime.now(UTC)
was_expired = subscription_before.status in ('expired', 'disabled', 'limited') or (
subscription_before.end_date is not None and subscription_before.end_date <= now
)
try:
subscription_after = await extend_subscription(db, subscription_before, period_days)
except Exception:
# Session may be in a failed state after a broken commit — rollback first
await db.rollback()
# Compensate: refund the charged balance since extension failed
if charge_from_balance > 0:
if charge_from_balance > 0 or (consume_promo_offer and saved_promo_percent > 0):
try:
from app.database.crud.user import add_user_balance
await add_user_balance(
db,
user,
charge_from_balance,
'Возврат: ошибка продления подписки',
create_transaction=True,
transaction_type=TransactionType.REFUND,
)
if charge_from_balance > 0:
refunded = await add_user_balance(
db,
user,
charge_from_balance,
'Возврат: ошибка продления подписки',
create_transaction=True,
transaction_type=TransactionType.REFUND,
)
if not refunded:
logger.critical(
'CRITICAL: add_user_balance returned False during refund',
charge_from_balance=charge_from_balance,
user_id=user.id,
)
# Restore consumed promo offer fields
if consume_promo_offer and saved_promo_percent > 0:
user.promo_offer_discount_percent = saved_promo_percent
user.promo_offer_discount_source = saved_promo_source
user.promo_offer_discount_expires_at = saved_promo_expires
await db.commit()
logger.info(
'Restored promo offer after failed extension',
user_id=user.id,
restored_percent=saved_promo_percent,
)
except Exception as refund_error:
logger.critical(
'CRITICAL: Failed to refund kopeks to user after extension failure',
@@ -507,8 +463,14 @@ class SubscriptionRenewalService:
)
raise
server_ids = pricing.server_ids or []
server_prices_for_period = pricing.details.get('servers_individual_prices', [])
# Support both SubscriptionRenewalPricing (server_ids, details) and RenewalPricing (breakdown)
if isinstance(pricing, SubscriptionRenewalPricing):
server_ids = pricing.server_ids or []
server_prices_for_period = (pricing.details or {}).get('servers_individual_prices', [])
else:
breakdown = pricing.breakdown or {}
server_ids = breakdown.get('server_ids', [])
server_prices_for_period = breakdown.get('servers_individual_prices', [])
if server_ids:
try:
await add_subscription_servers(
@@ -524,19 +486,29 @@ class SubscriptionRenewalService:
error=error,
)
reset_traffic = was_expired and settings.RESET_TRAFFIC_ON_PAYMENT
subscription_service = SubscriptionService()
try:
await subscription_service.update_remnawave_user(
db,
subscription_after,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason='subscription renewal',
)
await db.refresh(user)
if getattr(user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(
db,
subscription_after,
reset_traffic=reset_traffic,
reset_reason='subscription renewal',
)
else:
await subscription_service.create_remnawave_user(
db,
subscription_after,
reset_traffic=reset_traffic,
reset_reason='subscription renewal',
)
except RemnaWaveConfigurationError as error: # pragma: no cover - configuration issues
logger.warning('RemnaWave update skipped', error=error)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
'Failed to update RemnaWave user for subscription',
'Failed to sync RemnaWave user for subscription',
subscription_after_id=subscription_after.id,
error=error,
)
@@ -583,76 +555,6 @@ class SubscriptionRenewalService:
old_end_date=old_end_date,
)
async def _validate_servers_for_user_promo_group(
self,
db: AsyncSession,
user: User,
server_uuids: list[str],
) -> None:
"""
Проверяет, что все серверы подписки доступны для промогруппы пользователя.
Логирует предупреждения если серверы недоступны.
"""
if not server_uuids:
return
try:
await db.refresh(user, ['user_promo_groups', 'promo_group'])
except Exception:
pass
user_promo_group = user.get_primary_promo_group() if user else None
if not user_promo_group:
return
servers = await get_server_squads_by_uuids(db, server_uuids)
unavailable_servers = []
for server in servers:
if server.allowed_promo_groups:
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
if user_promo_group.id not in allowed_ids:
unavailable_servers.append(server.display_name or server.squad_uuid)
if unavailable_servers:
user_identifier = user.telegram_id or user.email or f'user#{user.id}'
logger.warning(
'⚠️ Пользователь (promo_group=) продлевает подписку с серверами, недоступными для его промогруппы: . Это может привести к неправильному расчёту цены!',
user_identifier=user_identifier,
user_promo_group_name=user_promo_group.name,
value=', '.join(unavailable_servers),
)
def build_option_payload(
self,
pricing: SubscriptionRenewalPricing,
*,
language: str,
) -> dict[str, Any]:
label = format_period_description(pricing.period_days, language)
price_label = settings.format_price(pricing.final_total)
original_label = None
if pricing.base_original_total and pricing.base_original_total != pricing.final_total:
original_label = settings.format_price(pricing.base_original_total)
per_month_label = settings.format_price(pricing.per_month)
payload = {
'id': pricing.period_id,
'days': pricing.period_days,
'months': pricing.months,
'price_kopeks': pricing.final_total,
'price_label': price_label,
'original_price_kopeks': pricing.base_original_total,
'original_price_label': original_label,
'discount_percent': pricing.overall_discount_percent,
'price_per_month_kopeks': pricing.per_month,
'price_per_month_label': per_month_label,
'title': label,
}
return payload
def calculate_missing_amount(balance_kopeks: int, total_kopeks: int) -> int:
if total_kopeks <= 0:
+5 -601
View File
@@ -14,6 +14,7 @@ from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Us
from app.external.remnawave_api import RemnaWaveAPI, RemnaWaveAPIError, RemnaWaveUser, TrafficLimitStrategy, UserStatus
from app.utils.pricing_utils import (
calculate_months_from_days,
resolve_discount_percent,
)
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
@@ -23,45 +24,6 @@ from app.utils.subscription_utils import (
logger = structlog.get_logger(__name__)
def _resolve_discount_percent(
user: User | None,
promo_group: PromoGroup | None,
category: str,
*,
period_days: int | None = None,
) -> int:
if user is not None:
try:
return user.get_promo_discount(category, period_days)
except AttributeError:
pass
if promo_group is not None:
return promo_group.get_discount_percent(category, period_days)
return 0
def _resolve_addon_discount_percent(
user: User | None,
promo_group: PromoGroup | None,
category: str,
*,
period_days: int | None = None,
) -> int:
group = promo_group or (user.get_primary_promo_group() if user else None)
if group is not None and not getattr(group, 'apply_discounts_to_addons', True):
return 0
return _resolve_discount_percent(
user,
promo_group,
category,
period_days=period_days,
)
def get_traffic_reset_strategy(tariff=None):
"""Получает стратегию сброса трафика.
@@ -711,284 +673,6 @@ class SubscriptionService:
logger.error('Ошибка синхронизации подписки', subscription_id=subscription.id, error=e)
return False, 'unknown_error'
async def calculate_subscription_price(
self,
period_days: int,
traffic_gb: int,
server_squad_ids: list[int],
devices: int,
db: AsyncSession,
*,
user: User | None = None,
promo_group: PromoGroup | None = None,
) -> tuple[int, list[int]]:
from app.config import PERIOD_PRICES
from app.database.crud.server_squad import get_server_squad_by_id
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
raise ValueError(f'Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}')
base_price_original = PERIOD_PRICES.get(period_days, 0)
period_discount_percent = _resolve_discount_percent(
user,
promo_group,
'period',
period_days=period_days,
)
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
traffic_price = settings.get_traffic_price(traffic_gb)
traffic_discount_percent = _resolve_discount_percent(
user,
promo_group,
'traffic',
period_days=period_days,
)
traffic_discount = traffic_price * traffic_discount_percent // 100
discounted_traffic_price = traffic_price - traffic_discount
server_prices = []
total_servers_price = 0
servers_discount_percent = _resolve_discount_percent(
user,
promo_group,
'servers',
period_days=period_days,
)
for server_id in server_squad_ids:
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available and not server.is_full:
server_price = server.price_kopeks
server_discount = server_price * servers_discount_percent // 100
discounted_server_price = server_price - server_discount
server_prices.append(discounted_server_price)
total_servers_price += discounted_server_price
log_message = f'Сервер {server.display_name}: {server_price / 100}'
if server_discount > 0:
log_message += f' (скидка {servers_discount_percent}%: -{server_discount / 100}₽ → {discounted_server_price / 100}₽)'
logger.debug(log_message)
else:
server_prices.append(0)
logger.warning('Сервер ID недоступен', server_id=server_id)
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
devices_discount_percent = _resolve_discount_percent(
user,
promo_group,
'devices',
period_days=period_days,
)
devices_discount = devices_price * devices_discount_percent // 100
discounted_devices_price = devices_price - devices_discount
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
logger.debug('Расчет стоимости новой подписки:')
base_log = f' Период {period_days} дней: {base_price_original / 100}'
if base_discount_total > 0:
base_log += f'{base_price / 100}₽ (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)'
logger.debug(base_log)
if discounted_traffic_price > 0:
message = f' Трафик {traffic_gb} ГБ: {traffic_price / 100}'
if traffic_discount > 0:
message += f' (скидка {traffic_discount_percent}%: -{traffic_discount / 100}₽ → {discounted_traffic_price / 100}₽)'
logger.debug(message)
if total_servers_price > 0:
message = f' Серверы ({len(server_squad_ids)}): {total_servers_price / 100}'
if servers_discount_percent > 0:
message += f' (скидка {servers_discount_percent}% применяется ко всем серверам)'
logger.debug(message)
if discounted_devices_price > 0:
message = f' Устройства ({devices}): {devices_price / 100}'
if devices_discount > 0:
message += f' (скидка {devices_discount_percent}%: -{devices_discount / 100}₽ → {discounted_devices_price / 100}₽)'
logger.debug(message)
logger.debug('ИТОГО: ₽', total_price=total_price / 100)
return total_price, server_prices
async def calculate_renewal_price(
self,
subscription: Subscription,
period_days: int,
db: AsyncSession,
*,
user: User | None = None,
promo_group: PromoGroup | None = None,
) -> int:
try:
from app.config import PERIOD_PRICES
if user is None:
user = getattr(subscription, 'user', None)
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
tariff = getattr(subscription, 'tariff', None)
tariff_price = tariff.get_price_for_period(period_days) if tariff else None
is_tariff_pricing = tariff_price is not None
if is_tariff_pricing:
# --- ТАРИФНЫЙ РЕЖИМ ---
# tariff.period_prices — итоговая цена тарифа (серверы, трафик включены).
# Сверху добавляем стоимость доп. устройств сверх лимита тарифа.
# Порядок: база + устройства → скидка на полную сумму (как в cabinet).
original_price = tariff_price
# Доп. устройства сверх лимита тарифа
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
device_price_per_unit = (
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
)
device_limit = (
subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
)
extra_devices = max(0, device_limit - tariff_device_limit)
months = calculate_months_from_days(period_days)
devices_price = extra_devices * device_price_per_unit * months
original_price += devices_price
# Скидка промогруппы на полную сумму (база + устройства)
period_discount_percent = _resolve_discount_percent(
user,
promo_group,
'period',
period_days=period_days,
)
discount_total = original_price * period_discount_percent // 100
total_price = original_price - discount_total
# Promo-offer скидка (временная скидка, как в cabinet)
from app.utils.promo_offer import get_user_active_promo_discount_percent
promo_offer_percent = get_user_active_promo_discount_percent(user)
promo_offer_discount = 0
if promo_offer_percent > 0:
promo_offer_discount = total_price * promo_offer_percent // 100
total_price = total_price - promo_offer_discount
logger.debug(
'💰 Расчет стоимости продления (тариф) для подписки',
subscription_id=subscription.id,
tariff_name=tariff.name,
)
base_log = f' 📅 Тариф «{tariff.name}», период {period_days} дней: {tariff_price / 100}'
if devices_price > 0:
base_log += f' + устройства ({extra_devices} сверх {tariff_device_limit}): {devices_price / 100}'
logger.debug(base_log)
if discount_total > 0:
logger.debug(f' 🏷️ Скидка промогруппы {period_discount_percent}%: -{discount_total / 100}')
if promo_offer_discount > 0:
logger.debug(f' 🎁 Promo-offer скидка {promo_offer_percent}%: -{promo_offer_discount / 100}')
logger.debug('💎 ИТОГО: ₽', total_price=total_price / 100)
else:
# --- КЛАССИК РЕЖИМ ---
# base (PERIOD_PRICES) + серверы + трафик + устройства
base_price_original = PERIOD_PRICES.get(period_days, 0)
servers_price, _ = await self.get_countries_price_by_uuids(
subscription.connected_squads,
db,
promo_group_id=promo_group.id if promo_group else None,
)
servers_discount_percent = _resolve_discount_percent(
user,
promo_group,
'servers',
period_days=period_days,
)
servers_discount = servers_price * servers_discount_percent // 100
discounted_servers_price = servers_price - servers_discount
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
forced_limit = settings.get_disabled_mode_device_limit()
device_limit = forced_limit if forced_limit is not None else settings.DEFAULT_DEVICE_LIMIT
devices_price = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
devices_discount_percent = _resolve_discount_percent(
user,
promo_group,
'devices',
period_days=period_days,
)
devices_discount = devices_price * devices_discount_percent // 100
discounted_devices_price = devices_price - devices_discount
# Трафик: вычитаем purchased_traffic_gb чтобы не завышать тир
purchased_traffic = subscription.purchased_traffic_gb or 0
if settings.is_traffic_fixed():
renewal_traffic_gb = settings.get_fixed_traffic_limit()
elif purchased_traffic > 0:
base_traffic = (subscription.traffic_limit_gb or 0) - purchased_traffic
renewal_traffic_gb = base_traffic if base_traffic > 0 else subscription.traffic_limit_gb
else:
renewal_traffic_gb = subscription.traffic_limit_gb
traffic_price = settings.get_traffic_price(renewal_traffic_gb)
traffic_discount_percent = _resolve_discount_percent(
user,
promo_group,
'traffic',
period_days=period_days,
)
traffic_discount = traffic_price * traffic_discount_percent // 100
discounted_traffic_price = traffic_price - traffic_discount
period_discount_percent = _resolve_discount_percent(
user,
promo_group,
'period',
period_days=period_days,
)
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
total_price = (
base_price + discounted_servers_price + discounted_devices_price + discounted_traffic_price
)
logger.debug(
'💰 Расчет стоимости продления (классик) для подписки',
subscription_id=subscription.id,
)
base_log = f' 📅 Период {period_days} дней: {base_price_original / 100}'
if base_discount_total > 0:
base_log += f'{base_price / 100}₽ (скидка {period_discount_percent}%)'
logger.debug(base_log)
if servers_price > 0:
message = f' 🌍 Серверы ({len(subscription.connected_squads)}): {discounted_servers_price / 100}'
if servers_discount > 0:
message += f' (скидка {servers_discount_percent}%: -{servers_discount / 100}₽)'
logger.debug(message)
if devices_price > 0:
message = f' 📱 Устройства ({device_limit}): {discounted_devices_price / 100}'
if devices_discount > 0:
message += f' (скидка {devices_discount_percent}%: -{devices_discount / 100}₽)'
logger.debug(message)
if traffic_price > 0:
message = f' 📊 Трафик ({renewal_traffic_gb} ГБ): {discounted_traffic_price / 100}'
if traffic_discount > 0:
message += f' (скидка {traffic_discount_percent}%: -{traffic_discount / 100}₽)'
logger.debug(message)
logger.debug('💎 ИТОГО: ₽', total_price=total_price / 100)
return total_price
except Exception as e:
logger.error('Ошибка расчета стоимости продления', error=e, exc_info=True)
# Не возвращаем 0 — это приведёт к бесплатному продлению.
# Пробрасываем ошибку, чтобы вызывающий код решал что делать.
raise
async def validate_and_clean_subscription(self, db: AsyncSession, subscription: Subscription, user: User) -> bool:
try:
needs_cleanup = False
@@ -1091,14 +775,6 @@ class SubscriptionService:
default_prices = [0] * len(country_uuids)
return sum(default_prices), default_prices
async def _get_countries_price(self, country_uuids: list[str], db: AsyncSession) -> int:
try:
total_price, _ = await self.get_countries_price_by_uuids(country_uuids, db)
return total_price
except Exception as e:
logger.error('Ошибка получения цен стран', error=e)
return len(country_uuids) * 1000
async def calculate_subscription_price_with_months(
self,
period_days: int,
@@ -1119,7 +795,7 @@ class SubscriptionService:
months_in_period = calculate_months_from_days(period_days)
base_price_original = PERIOD_PRICES.get(period_days, 0)
period_discount_percent = _resolve_discount_percent(
period_discount_percent = resolve_discount_percent(
user,
promo_group,
'period',
@@ -1131,7 +807,7 @@ class SubscriptionService:
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
traffic_discount_percent = _resolve_discount_percent(
traffic_discount_percent = resolve_discount_percent(
user,
promo_group,
'traffic',
@@ -1143,7 +819,7 @@ class SubscriptionService:
server_prices = []
total_servers_price = 0
servers_discount_percent = _resolve_discount_percent(
servers_discount_percent = resolve_discount_percent(
user,
promo_group,
'servers',
@@ -1171,7 +847,7 @@ class SubscriptionService:
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = _resolve_discount_percent(
devices_discount_percent = resolve_discount_percent(
user,
promo_group,
'devices',
@@ -1213,278 +889,6 @@ class SubscriptionService:
return total_price, server_prices
async def calculate_renewal_price_with_months(
self,
subscription: Subscription,
period_days: int,
db: AsyncSession,
*,
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)
if user is None:
user = getattr(subscription, 'user', None)
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
tariff = getattr(subscription, 'tariff', None)
tariff_price = tariff.get_price_for_period(period_days) if tariff else None
is_tariff_pricing = tariff_price is not None
if is_tariff_pricing:
# --- ТАРИФНЫЙ РЕЖИМ ---
# Порядок: база + устройства → скидка на полную сумму (как в cabinet).
original_price = tariff_price
# Доп. устройства сверх лимита тарифа
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
device_price_per_unit = (
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
)
device_limit = (
subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
)
extra_devices = max(0, device_limit - tariff_device_limit)
devices_price_total = extra_devices * device_price_per_unit * months_in_period
original_price += devices_price_total
# Скидка промогруппы на полную сумму (база + устройства)
period_discount_percent = _resolve_discount_percent(
user,
promo_group,
'period',
period_days=period_days,
)
discount_total = original_price * period_discount_percent // 100
total_price = original_price - discount_total
# Promo-offer скидка (временная скидка, как в cabinet)
from app.utils.promo_offer import get_user_active_promo_discount_percent
promo_offer_percent = get_user_active_promo_discount_percent(user)
promo_offer_discount = 0
if promo_offer_percent > 0:
promo_offer_discount = total_price * promo_offer_percent // 100
total_price = total_price - promo_offer_discount
logger.debug(
'💰 Расчет стоимости продления (тариф) на дней ( мес)',
subscription_id=subscription.id,
period_days=period_days,
months_in_period=months_in_period,
tariff_name=tariff.name,
)
base_log = f' 📅 Тариф «{tariff.name}», период {period_days} дней: {tariff_price / 100}'
if devices_price_total > 0:
base_log += (
f' + устройства ({extra_devices} сверх {tariff_device_limit}): {devices_price_total / 100}'
)
logger.debug(base_log)
if discount_total > 0:
logger.debug(f' 🏷️ Скидка промогруппы {period_discount_percent}%: -{discount_total / 100}')
if promo_offer_discount > 0:
logger.debug(f' 🎁 Promo-offer скидка {promo_offer_percent}%: -{promo_offer_discount / 100}')
logger.debug('💎 ИТОГО: ₽', total_price=total_price / 100)
else:
# --- КЛАССИК РЕЖИМ ---
base_price_original = PERIOD_PRICES.get(period_days, 0)
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
subscription.connected_squads,
db,
promo_group_id=promo_group.id if promo_group else None,
)
servers_discount_percent = _resolve_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_price = discounted_servers_per_month * months_in_period
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
forced_limit = settings.get_disabled_mode_device_limit()
device_limit = forced_limit if forced_limit is not None else settings.DEFAULT_DEVICE_LIMIT
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = _resolve_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_price = discounted_devices_per_month * months_in_period
# Трафик: вычитаем purchased_traffic_gb чтобы не завышать тир
purchased_traffic = subscription.purchased_traffic_gb or 0
if settings.is_traffic_fixed():
renewal_traffic_gb = settings.get_fixed_traffic_limit()
elif purchased_traffic > 0:
base_traffic = (subscription.traffic_limit_gb or 0) - purchased_traffic
renewal_traffic_gb = base_traffic if base_traffic > 0 else subscription.traffic_limit_gb
else:
renewal_traffic_gb = subscription.traffic_limit_gb
traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb)
traffic_discount_percent = _resolve_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_price = discounted_traffic_per_month * months_in_period
period_discount_percent = _resolve_discount_percent(
user,
promo_group,
'period',
period_days=period_days,
)
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
logger.debug(
'💰 Расчет стоимости продления (классик) на дней ( мес)',
subscription_id=subscription.id,
period_days=period_days,
months_in_period=months_in_period,
)
base_log = f' 📅 Период {period_days} дней: {base_price_original / 100}'
if base_discount_total > 0:
base_log += f'{base_price / 100}₽ (скидка {period_discount_percent}%)'
logger.debug(base_log)
if total_servers_price > 0:
message = f' 🌍 Серверы: {servers_price_per_month / 100}₽/мес x {months_in_period} = {total_servers_price / 100}'
if servers_discount_per_month > 0:
message += f' (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period / 100}₽)'
logger.debug(message)
if total_devices_price > 0:
message = f' 📱 Устройства: {devices_price_per_month / 100}₽/мес x {months_in_period} = {total_devices_price / 100}'
if devices_discount_per_month > 0:
message += f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period / 100}₽)'
logger.debug(message)
if total_traffic_price > 0:
message = f' 📊 Трафик: {traffic_price_per_month / 100}₽/мес x {months_in_period} = {total_traffic_price / 100}'
if traffic_discount_per_month > 0:
message += f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period / 100}₽)'
logger.debug(message)
logger.debug('💎 ИТОГО: ₽', total_price=total_price / 100)
return total_price
except Exception as e:
logger.error('Ошибка расчета стоимости продления (with_months)', error=e, exc_info=True)
raise
async def calculate_addon_price_with_remaining_period(
self,
subscription: Subscription,
additional_traffic_gb: int = 0,
additional_devices: int = 0,
additional_server_ids: list[int] = None,
db: AsyncSession = None,
) -> int:
if additional_server_ids is None:
additional_server_ids = []
now = datetime.now(UTC)
days_to_pay = max(1, (subscription.end_date - now).days)
period_hint_days = days_to_pay
user = getattr(subscription, 'user', None)
promo_group = user.promo_group if user else None
total_price = 0
if additional_traffic_gb > 0:
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
traffic_discount_percent = _resolve_addon_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_price = int(discounted_traffic_per_month * days_to_pay / 30)
total_price += traffic_total_price
message = (
f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес x {days_to_pay} дн.'
f' = {traffic_total_price / 100}'
)
if traffic_discount_per_month > 0:
message += f' (скидка {traffic_discount_percent}%: -{int(traffic_discount_per_month * days_to_pay / 30) / 100}₽)'
logger.info(message)
if additional_devices > 0:
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = _resolve_addon_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_price = int(discounted_devices_per_month * days_to_pay / 30)
total_price += devices_total_price
message = (
f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес x {days_to_pay} дн.'
f' = {devices_total_price / 100}'
)
if devices_discount_per_month > 0:
message += f' (скидка {devices_discount_percent}%: -{int(devices_discount_per_month * days_to_pay / 30) / 100}₽)'
logger.info(message)
if additional_server_ids and db:
for server_id in additional_server_ids:
from app.database.crud.server_squad import get_server_squad_by_id
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available:
server_price_per_month = server.price_kopeks
servers_discount_percent = _resolve_addon_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_price = int(discounted_server_per_month * days_to_pay / 30)
total_price += server_total_price
message = (
f'Сервер {server.display_name}: {server_price_per_month / 100}₽/мес x {days_to_pay} дн.'
f' = {server_total_price / 100}'
)
if server_discount_per_month > 0:
message += (
f' (скидка {servers_discount_percent}%:'
f' -{int(server_discount_per_month * days_to_pay / 30) / 100}₽)'
)
logger.info(message)
logger.info('Итого доплата за дн.: ₽', days_to_pay=days_to_pay, total_price=total_price / 100)
return total_price
def _gb_to_bytes(self, gb: int | None) -> int:
if not gb: # None or 0
return 0
+20 -3
View File
@@ -141,6 +141,11 @@ class TributeService:
description=f'Пополнение через Tribute: {amount_kopeks / 100}₽ (ID: {payment_id})',
)
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(session, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
@@ -247,12 +252,19 @@ class TributeService:
payment_method=PaymentMethod.TRIBUTE,
external_id=f'refund_{payment_id}',
is_completed=True,
commit=False,
)
user = await get_user_by_telegram_id(session, user_id)
if user and user.balance_kopeks >= amount_kopeks:
user.balance_kopeks -= amount_kopeks
await session.commit()
if user:
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(session, user)
if user.balance_kopeks >= amount_kopeks:
user.balance_kopeks -= amount_kopeks
await session.commit()
await self._send_refund_notification(user_id, amount_kopeks)
@@ -394,6 +406,11 @@ class TributeService:
is_completed=True,
)
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(session, user)
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.now(UTC)
+4
View File
@@ -1246,10 +1246,14 @@ class UserService:
AccessPolicy,
AdminAuditLog,
AdminRole,
RioPayPayment,
SavedPaymentMethod,
UserRole,
WithdrawalRequest,
)
await db.execute(delete(SavedPaymentMethod).where(SavedPaymentMethod.user_id == user_id))
await db.execute(delete(RioPayPayment).where(RioPayPayment.user_id == user_id))
await db.execute(delete(AdminAuditLog).where(AdminAuditLog.user_id == user_id))
await db.execute(delete(WithdrawalRequest).where(WithdrawalRequest.user_id == user_id))
await db.execute(
+8 -1
View File
@@ -15,6 +15,10 @@ from app.config import settings
logger = structlog.get_logger(__name__)
# WATA API rejects expirationDateTime <= now + 10 minutes (exclusive lower bound).
# 15 minutes provides a 5-minute buffer against clock skew and request latency.
_MIN_EXPIRATION_MINUTES = 15
class WataAPIError(RuntimeError):
"""Raised when the WATA API returns an error response."""
@@ -194,7 +198,10 @@ class WataService:
expiration_minutes = int(ttl) if ttl is not None else None
if expiration_minutes:
expiration_time = datetime.now(UTC) + timedelta(minutes=expiration_minutes)
# WATA API требует expirationDateTime строго > now + 10 минут.
# Принудительный минимум 15 минут, чтобы не попасть на границу.
safe_minutes = max(expiration_minutes, _MIN_EXPIRATION_MINUTES)
expiration_time = datetime.now(UTC) + timedelta(minutes=safe_minutes)
payload['expirationDateTime'] = self._format_datetime(expiration_time)
if allow_arbitrary_amount:
+4
View File
@@ -276,6 +276,10 @@ class FortuneWheelService:
rubles = Decimal(config.spin_cost_stars) * stars_rate
kopeks = int(rubles * 100)
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
if user.balance_kopeks < kopeks:
raise ValueError('Недостаточно средств на балансе')
+34
View File
@@ -0,0 +1,34 @@
"""Shared formatting utilities for traffic, price, and period display."""
def format_traffic(gb: int) -> str:
"""Форматирует трафик."""
if gb == 0:
return 'Безлимит'
return f'{gb} ГБ'
def format_price_kopeks(kopeks: int, compact: bool = False) -> str:
"""Форматирует цену из копеек в рубли."""
rubles = kopeks / 100
if compact:
# Компактный формат - округляем до рублей
return f'{int(round(rubles))}'
if rubles == int(rubles):
return f'{int(rubles)}'
return f'{rubles:.2f}'
def format_period(days: int) -> str:
"""Форматирует период."""
mod100 = days % 100
mod10 = days % 10
if 11 <= mod100 <= 19:
word = 'дней'
elif mod10 == 1:
word = 'день'
elif 2 <= mod10 <= 4:
word = 'дня'
else:
word = 'дней'
return f'{days} {word}'
+17 -51
View File
@@ -18,20 +18,6 @@ def calculate_months_from_days(days: int) -> int:
return max(1, round(days / 30))
def calculate_period_multiplier(period_days: int) -> tuple[int, float]:
exact_months = period_days / 30
months_count = max(1, round(exact_months))
logger.debug(
'Период дней точных месяцев ≈ месяцев для расчета',
period_days=period_days,
exact_months=round(exact_months, 2),
months_count=months_count,
)
return months_count, exact_months
def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_days: int = 30) -> tuple[int, int]:
"""Calculate prorated price based on remaining days.
@@ -42,7 +28,7 @@ def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_
days_remaining = max(1, (end_date - now).days)
days_to_charge = max(min_charge_days, days_remaining)
total_price = int(monthly_price * days_to_charge / 30)
total_price = monthly_price * days_to_charge // 30
if monthly_price > 0:
total_price = max(100, total_price) # Минимум 1 рубль
@@ -57,29 +43,17 @@ def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_
def apply_percentage_discount(amount: int, percent: int) -> tuple[int, int]:
"""Apply percentage discount using PricingEngine's floor division.
Returns (discounted_amount, discount_value).
"""
from app.services.pricing_engine import PricingEngine
if amount <= 0 or percent <= 0:
return amount, 0
clamped_percent = max(0, min(100, percent))
discount_value = amount * clamped_percent // 100
discounted_amount = amount - discount_value
# Round the discounted price up to the nearest full ruble (100 kopeks)
# to avoid undercharging users because of fractional kopeks.
if discount_value >= 100 and discounted_amount % 100:
discounted_amount += 100 - (discounted_amount % 100)
discounted_amount = min(discounted_amount, amount)
discount_value = amount - discounted_amount
logger.debug(
'Применена скидка %: → (скидка)',
clamped_percent=clamped_percent,
amount=amount,
discounted_amount=discounted_amount,
discount_value=discount_value,
)
return discounted_amount, discount_value
discounted = PricingEngine.apply_discount(amount, percent)
return discounted, amount - discounted
def resolve_discount_percent(
@@ -189,14 +163,20 @@ async def compute_simple_subscription_price(
elif raw_squad:
resolved_uuids.append(str(raw_squad))
from app.database.crud.server_squad import get_server_squad_by_uuid
from app.database.crud.server_squad import get_server_squads_by_uuids
server_breakdown: list[dict[str, Any]] = []
servers_price_original = 0
servers_discount_total = 0
if resolved_uuids:
servers = await get_server_squads_by_uuids(db, resolved_uuids)
server_map = {s.squad_uuid: s for s in servers}
else:
server_map = {}
for squad_uuid in resolved_uuids:
server = await get_server_squad_by_uuid(db, squad_uuid)
server = server_map.get(squad_uuid)
if not server:
logger.warning('SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad', squad_uuid=squad_uuid)
server_breakdown.append(
@@ -340,17 +320,3 @@ def validate_pricing_calculation(base_price: int, monthly_additions: int, months
)
return is_valid
STANDARD_PERIODS = {
14: {'months': 0.5, 'display_ru': '2 недели', 'display_en': '2 weeks'},
30: {'months': 1, 'display_ru': '1 месяц', 'display_en': '1 month'},
60: {'months': 2, 'display_ru': '2 месяца', 'display_en': '2 months'},
90: {'months': 3, 'display_ru': '3 месяца', 'display_en': '3 months'},
180: {'months': 6, 'display_ru': '6 месяцев', 'display_en': '6 months'},
360: {'months': 12, 'display_ru': '1 год', 'display_en': '1 year'},
}
def get_period_info(days: int) -> dict:
return STANDARD_PERIODS.get(days)
+206 -322
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import dataclasses
import math
import re
from collections.abc import Collection
@@ -2867,10 +2868,9 @@ async def _build_referral_info(
referral_code = getattr(user, 'referral_code', None)
referral_settings = settings.get_referral_settings() or {}
bot_username = settings.get_bot_username()
referral_link = None
if referral_code and bot_username:
referral_link = f'https://t.me/{bot_username}?start={referral_code}'
if referral_code:
referral_link = settings.get_referral_link(referral_code)
minimum_topup_kopeks = int(referral_settings.get('minimum_topup_kopeks') or 0)
first_topup_bonus_kopeks = int(referral_settings.get('first_topup_bonus_kopeks') or 0)
@@ -4498,150 +4498,94 @@ def _parse_period_identifier(identifier: str | None) -> int | None:
return None
async def _calculate_subscription_renewal_pricing(
db: AsyncSession,
user: User,
subscription: Subscription,
period_days: int,
):
return await renewal_service.calculate_pricing(
db,
user,
subscription,
period_days,
)
async def _prepare_subscription_renewal_options(
db: AsyncSession,
user: User,
subscription: Subscription,
) -> tuple[list[MiniAppSubscriptionRenewalPeriod], dict[str | int, dict[str, Any]], str | None]:
from app.services.pricing_engine import pricing_engine
option_payloads: list[tuple[MiniAppSubscriptionRenewalPeriod, dict[str, Any]]] = []
# Проверяем, есть ли у подписки тариф (режим тарифов)
# Определяем доступные периоды: из тарифа или из настроек
tariff_id = getattr(subscription, 'tariff_id', None)
tariff = None
if tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, tariff_id)
if tariff and tariff.period_prices:
# Режим тарифов: используем периоды и цены из тарифа
promo_group = (
user.get_primary_promo_group()
if hasattr(user, 'get_primary_promo_group')
else getattr(user, 'promo_group', None)
available_periods = sorted(int(k) for k in tariff.period_prices.keys())
else:
available_periods = [p for p in settings.get_available_renewal_periods() if p > 0]
for period_days in available_periods:
try:
pricing_result = await pricing_engine.calculate_renewal_price(
db,
subscription,
period_days,
user=user,
)
except Exception as error: # pragma: no cover - defensive logging
logger.warning(
'Failed to calculate renewal pricing for subscription (period)',
subscription_id=subscription.id,
period_days=period_days,
error=error,
)
continue
# Вычисляем оригинальную цену (до скидок) для отображения зачёркнутой цены
original_price = pricing_result.original_total
has_discount = original_price > pricing_result.final_total and original_price > 0
discount_percent = (
int((original_price - pricing_result.final_total) * 100 / original_price) if has_discount else 0
)
# Получаем скидки промогруппы по периодам
period_discounts = {}
if promo_group:
raw_discounts = getattr(promo_group, 'period_discounts', None) or {}
for k, v in raw_discounts.items():
try:
period_discounts[int(k)] = max(0, min(100, int(v)))
except (TypeError, ValueError):
pass
months = max(1, period_days // 30)
per_month = pricing_result.final_total // months if months > 0 else pricing_result.final_total
for period_str, original_price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])):
period_days = int(period_str)
label = format_period_description(
period_days,
getattr(user, 'language', settings.DEFAULT_LANGUAGE),
)
# Применяем скидку промогруппы
discount_percent = period_discounts.get(period_days, 0)
if discount_percent > 0:
price_kopeks = int(original_price_kopeks * (100 - discount_percent) / 100)
else:
price_kopeks = original_price_kopeks
price_label = settings.format_price(pricing_result.final_total)
original_label = settings.format_price(original_price) if has_discount else None
per_month_label = settings.format_price(per_month)
months = max(1, period_days // 30)
per_month = price_kopeks // months if months > 0 else price_kopeks
period_id = (
f'tariff_{tariff.id}_{period_days}' if pricing_result.is_tariff_mode and tariff else f'days:{period_days}'
)
label = format_period_description(
period_days,
getattr(user, 'language', settings.DEFAULT_LANGUAGE),
)
option_model = MiniAppSubscriptionRenewalPeriod(
id=period_id,
days=period_days,
months=months,
price_kopeks=pricing_result.final_total,
price_label=price_label,
original_price_kopeks=original_price if has_discount else None,
original_price_label=original_label,
discount_percent=discount_percent,
price_per_month_kopeks=per_month,
price_per_month_label=per_month_label,
title=label,
)
price_label = settings.format_price(price_kopeks)
original_label = settings.format_price(original_price_kopeks) if discount_percent > 0 else None
per_month_label = settings.format_price(per_month)
pricing = {
'period_id': period_id,
'period_days': period_days,
'months': months,
'final_total': pricing_result.final_total,
'base_original_total': original_price if has_discount else pricing_result.final_total,
'overall_discount_percent': discount_percent,
'per_month': per_month,
'promo_offer_discount': pricing_result.promo_offer_discount,
}
if pricing_result.is_tariff_mode and tariff:
pricing['tariff_id'] = tariff.id
option_model = MiniAppSubscriptionRenewalPeriod(
id=f'tariff_{tariff.id}_{period_days}',
days=period_days,
months=months,
price_kopeks=price_kopeks,
price_label=price_label,
original_price_kopeks=original_price_kopeks if discount_percent > 0 else None,
original_price_label=original_label,
discount_percent=discount_percent,
price_per_month_kopeks=per_month,
price_per_month_label=per_month_label,
title=label,
)
pricing = {
'period_id': option_model.id,
'period_days': period_days,
'months': months,
'final_total': price_kopeks,
'base_original_total': original_price_kopeks if discount_percent > 0 else price_kopeks,
'overall_discount_percent': discount_percent,
'per_month': per_month,
'tariff_id': tariff.id,
}
option_payloads.append((option_model, pricing))
else:
# Классический режим: используем периоды из настроек
available_periods = [period for period in settings.get_available_renewal_periods() if period > 0]
for period_days in available_periods:
try:
pricing_model = await _calculate_subscription_renewal_pricing(
db,
user,
subscription,
period_days,
)
pricing = pricing_model.to_payload()
except Exception as error: # pragma: no cover - defensive logging
logger.warning(
'Failed to calculate renewal pricing for subscription (period)',
subscription_id=subscription.id,
period_days=period_days,
error=error,
)
continue
label = format_period_description(
period_days,
getattr(user, 'language', settings.DEFAULT_LANGUAGE),
)
price_label = settings.format_price(pricing['final_total'])
original_label = None
if pricing['base_original_total'] and pricing['base_original_total'] != pricing['final_total']:
original_label = settings.format_price(pricing['base_original_total'])
per_month_label = settings.format_price(pricing['per_month'])
option_model = MiniAppSubscriptionRenewalPeriod(
id=pricing['period_id'],
days=period_days,
months=pricing['months'],
price_kopeks=pricing['final_total'],
price_label=price_label,
original_price_kopeks=pricing['base_original_total'],
original_price_label=original_label,
discount_percent=pricing['overall_discount_percent'],
price_per_month_kopeks=pricing['per_month'],
price_per_month_label=per_month_label,
title=label,
)
option_payloads.append((option_model, pricing))
option_payloads.append((option_model, pricing))
if not option_payloads:
return [], {}, None
@@ -4816,6 +4760,11 @@ def _ensure_paid_subscription(
'code': 'subscription_disabled',
'message': 'Subscription is disabled',
}
elif actual_status == 'limited':
detail = {
'code': 'traffic_exhausted',
'message': 'Traffic limit reached. Please purchase additional traffic.',
}
else:
detail = {
'code': 'subscription_inactive',
@@ -5218,18 +5167,15 @@ async def submit_subscription_renewal_endpoint(
detail={'code': 'invalid_period', 'message': 'Invalid renewal period'},
)
# Проверяем, есть ли у подписки тариф (режим тарифов)
# Валидация периода и расчёт цены через PricingEngine
from app.services.pricing_engine import pricing_engine
tariff_id = getattr(subscription, 'tariff_id', None)
tariff = None
tariff_pricing = None
if tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, tariff_id)
if tariff and tariff.period_prices:
# Режим тарифов: проверяем периоды из тарифа
available_periods = [int(p) for p in tariff.period_prices.keys()]
if period_days not in available_periods:
raise HTTPException(
@@ -5239,200 +5185,75 @@ async def submit_subscription_renewal_endpoint(
'message': 'Selected renewal period is not available for this tariff',
},
)
# Рассчитываем цену из тарифа
original_price_kopeks = tariff.period_prices.get(str(period_days), tariff.period_prices.get(period_days, 0))
# Добавляем стоимость докупленных устройств сверх тарифа (ДО скидки, как в cabinet)
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
sub_device_limit = subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
extra_devices = max(0, sub_device_limit - tariff_device_limit)
if extra_devices > 0:
from app.utils.pricing_utils import calculate_months_from_days
device_price = (
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
)
months = calculate_months_from_days(period_days)
original_price_kopeks += extra_devices * device_price * months
# Применяем скидку промогруппы (к полной сумме: тариф + доп. устройства)
discount_percent = 0
if hasattr(user, 'get_promo_discount'):
discount_percent = user.get_promo_discount('period', period_days)
final_total = original_price_kopeks
if discount_percent > 0:
final_total = int(original_price_kopeks * (100 - discount_percent) / 100)
# Применяем promo_offer скидку (временная скидка, как в cabinet)
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
if promo_offer_discount_percent > 0:
promo_offer_discount_value = final_total * promo_offer_discount_percent // 100
final_total = final_total - promo_offer_discount_value
# Комбинированный процент скидки для отображения
combined_discount_percent = discount_percent
if promo_offer_discount_percent > 0 and original_price_kopeks > 0:
total_discount = original_price_kopeks - final_total
combined_discount_percent = int(total_discount * 100 / original_price_kopeks)
tariff_pricing = {
'period_days': period_days,
'original_price_kopeks': original_price_kopeks,
'discount_percent': combined_discount_percent,
'final_total': final_total,
'tariff_id': tariff.id,
}
else:
# Классический режим
available_periods = [period for period in settings.get_available_renewal_periods() if period > 0]
available_periods = [p for p in settings.get_available_renewal_periods() if p > 0]
if period_days not in available_periods:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail={'code': 'period_unavailable', 'message': 'Selected renewal period is not available'},
)
try:
pricing_result = await pricing_engine.calculate_renewal_price(db, subscription, period_days, user=user)
except HTTPException:
raise
except Exception as error:
logger.error(
'Failed to calculate renewal pricing for subscription (period)',
subscription_id=subscription.id,
period_days=period_days,
error=error,
)
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
detail={'code': 'pricing_failed', 'message': 'Failed to calculate renewal pricing'},
) from error
final_total = pricing_result.final_total
promo_offer_discount_value = pricing_result.promo_offer_discount
method = (payload.method or '').strip().lower()
# Для тарифного режима используем упрощённый расчёт
if tariff_pricing:
final_total = tariff_pricing['final_total']
pricing = tariff_pricing
else:
try:
pricing_model = await _calculate_subscription_renewal_pricing(
db,
user,
subscription,
period_days,
)
except HTTPException:
raise
except Exception as error:
logger.error(
'Failed to calculate renewal pricing for subscription (period)',
subscription_id=subscription.id,
period_days=period_days,
error=error,
)
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
detail={'code': 'pricing_failed', 'message': 'Failed to calculate renewal pricing'},
) from error
pricing = pricing_model.to_payload()
final_total = int(pricing_model.final_total)
balance_kopeks = getattr(user, 'balance_kopeks', 0)
missing_amount = calculate_missing_amount(balance_kopeks, final_total)
description = f'Продление подписки на {period_days} дней'
if missing_amount <= 0:
if tariff_pricing:
# Тарифный режим: простое продление
from app.database.crud.subscription import extend_subscription
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
try:
# Списываем баланс (subtract_user_balance делает commit и обновляет user.balance_kopeks)
success = await subtract_user_balance(
db,
user,
final_total,
description,
consume_promo_offer=promo_offer_discount_percent > 0,
mark_as_paid_subscription=True,
)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={'code': 'balance_error', 'message': 'Failed to subtract balance'},
)
# Продлеваем подписку
subscription = await extend_subscription(db, subscription, period_days)
new_end_date = subscription.end_date
# Записываем транзакцию
from app.database.models import TransactionType
await create_transaction(
db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=final_total,
description=description,
)
# Синхронизируем с RemnaWave (сброс трафика по настройке)
try:
from app.services.subscription_service import SubscriptionService
service = SubscriptionService()
await service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason='subscription renewal (miniapp)',
)
except Exception as e:
logger.error('Ошибка синхронизации с RemnaWave при продлении (miniapp)', error=e)
lang = getattr(user, 'language', settings.DEFAULT_LANGUAGE)
if lang == 'ru':
message = f'Подписка продлена до {new_end_date.strftime("%d.%m.%Y")}'
else:
message = f'Subscription extended until {new_end_date.strftime("%Y-%m-%d")}'
return MiniAppSubscriptionRenewalResponse(
message=message,
balance_kopeks=user.balance_kopeks,
balance_label=settings.format_price(user.balance_kopeks),
subscription_id=subscription.id,
renewed_until=new_end_date,
)
except Exception as error:
await db.rollback()
logger.error('Failed to renew tariff subscription', subscription_id=subscription.id, error=error)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={'code': 'renewal_failed', 'message': 'Failed to renew subscription'},
) from error
else:
# Классический режим
try:
result = await renewal_service.finalize(
db,
user,
subscription,
pricing_model,
description=description,
)
except SubscriptionRenewalChargeError as error:
logger.error(
'Failed to charge balance for subscription renewal', subscription_id=subscription.id, error=error
)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={'code': 'charge_failed', 'message': 'Failed to charge balance'},
) from error
updated_subscription = result.subscription
message = _build_renewal_success_message(
# Both tariff and classic modes use finalize() for consistent renewal handling
# (balance charge, extend, server recording, RemnaWave sync, transaction, admin notification)
try:
result = await renewal_service.finalize(
db,
user,
updated_subscription,
result.total_amount_kopeks,
pricing_model.promo_discount_value,
subscription,
pricing_result,
description=description,
payment_method=PaymentMethod.BALANCE,
)
except SubscriptionRenewalChargeError as error:
logger.error(
'Failed to charge balance for subscription renewal', subscription_id=subscription.id, error=error
)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={'code': 'charge_failed', 'message': 'Failed to charge balance'},
) from error
return MiniAppSubscriptionRenewalResponse(
message=message,
balance_kopeks=user.balance_kopeks,
balance_label=settings.format_price(user.balance_kopeks),
subscription_id=updated_subscription.id,
renewed_until=updated_subscription.end_date,
)
updated_subscription = result.subscription
message = _build_renewal_success_message(
user,
updated_subscription,
result.total_amount_kopeks,
promo_offer_discount_value,
)
return MiniAppSubscriptionRenewalResponse(
message=message,
balance_kopeks=user.balance_kopeks,
balance_label=settings.format_price(user.balance_kopeks),
subscription_id=updated_subscription.id,
renewed_until=updated_subscription.end_date,
)
if not method:
if final_total > 0 and balance_kopeks < final_total:
@@ -5502,7 +5323,7 @@ async def submit_subscription_renewal_endpoint(
period_days,
final_total,
missing_amount,
pricing_snapshot=pricing,
pricing_snapshot=dataclasses.asdict(pricing_result),
)
payload_value = encode_payment_payload(descriptor)
@@ -6620,6 +6441,15 @@ async def purchase_tariff_endpoint(
},
)
# Add extra device cost if user renews same tariff with purchased extra devices
subscription = getattr(user, 'subscription', None)
if not is_daily_tariff and subscription and subscription.tariff_id == tariff.id:
device_price_per_unit = (
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
)
extra_devices = max(0, (subscription.device_limit or 0) - (tariff.device_limit or 0))
base_price_kopeks += extra_devices * device_price_per_unit
# Применяем скидку промогруппы (только для обычных тарифов, не для суточных)
price_kopeks = base_price_kopeks
discount_percent = 0
@@ -6633,7 +6463,18 @@ async def purchase_tariff_endpoint(
except (TypeError, ValueError):
pass
if discount_percent > 0:
price_kopeks = int(base_price_kopeks * (100 - discount_percent) / 100)
from app.services.pricing_engine import PricingEngine
price_kopeks = PricingEngine.apply_discount(base_price_kopeks, discount_percent)
# Apply personal promo_offer discount on top of group discount
consume_promo_offer = False
if not is_daily_tariff:
promo_offer_pct = get_user_active_promo_discount_percent(user)
if promo_offer_pct > 0:
offer_discount_value = price_kopeks * promo_offer_pct // 100
price_kopeks = price_kopeks - offer_discount_value
consume_promo_offer = True
# Проверяем баланс
if user.balance_kopeks < price_kopeks:
@@ -6647,8 +6488,6 @@ async def purchase_tariff_endpoint(
},
)
subscription = getattr(user, 'subscription', None)
# Списываем баланс
if is_daily_tariff:
description = f"Активация суточного тарифа '{tariff.name}' (первый день)"
@@ -6661,6 +6500,7 @@ async def purchase_tariff_endpoint(
user,
price_kopeks,
description,
consume_promo_offer=consume_promo_offer,
mark_as_paid_subscription=True,
)
if not success:
@@ -6692,6 +6532,11 @@ async def purchase_tariff_endpoint(
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
if subscription:
# Preserve extra purchased devices when renewing the same tariff
if subscription.tariff_id == tariff.id:
effective_device_limit = max(tariff.device_limit or 0, subscription.device_limit or 0)
else:
effective_device_limit = tariff.device_limit
# Смена/продление тарифа
subscription = await extend_subscription(
db=db,
@@ -6699,7 +6544,7 @@ async def purchase_tariff_endpoint(
days=payload.period_days,
tariff_id=tariff.id,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
device_limit=effective_device_limit,
connected_squads=squads,
)
else:
@@ -6784,11 +6629,10 @@ def _get_user_period_discount(user, period_days: int) -> int:
def _apply_promo_discount(price: int, discount_percent: int) -> int:
"""Применяет скидку к цене."""
if discount_percent <= 0:
return price
discount = int(price * discount_percent / 100)
return max(0, price - discount)
"""Применяет скидку к цене (через PricingEngine для единообразия)."""
from app.services.pricing_engine import PricingEngine
return PricingEngine.apply_discount(price, discount_percent)
def _calculate_tariff_switch_cost(
@@ -6960,6 +6804,16 @@ async def switch_tariff_endpoint(
detail={'code': 'no_subscription', 'message': 'No active subscription with tariff'},
)
# Lock subscription row to prevent concurrent switch race condition
locked_result = await db.execute(
select(Subscription)
.where(Subscription.id == subscription.id)
.with_for_update()
.execution_options(populate_existing=True)
)
subscription = locked_result.scalar_one()
user.subscription = subscription
if subscription.status not in ('active', 'trial'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -7046,6 +6900,7 @@ async def switch_tariff_endpoint(
upgrade_cost,
description,
mark_as_paid_subscription=True,
commit=False,
)
if not success:
raise HTTPException(
@@ -7054,22 +6909,26 @@ async def switch_tariff_endpoint(
)
# Записываем транзакцию
await create_transaction(
switch_transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=upgrade_cost,
description=description,
payment_method=PaymentMethod.BALANCE,
commit=False,
)
else:
# Бесплатный переход (downgrade) — записываем в историю
description = f"Переход на тариф '{new_tariff.name}'"
switch_transaction = None
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=0,
description=description,
commit=False,
)
# Получаем список серверов из тарифа
@@ -7083,9 +6942,16 @@ async def switch_tariff_endpoint(
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
# Обновляем подписку - меняем тариф без изменения даты
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
subscription.tariff_id = new_tariff.id
subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
subscription.device_limit = new_tariff.device_limit
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 = squads
# Сбрасываем докупленный трафик при смене тарифа
from sqlalchemy import delete as sql_delete
@@ -7126,6 +6992,20 @@ async def switch_tariff_endpoint(
logger.info('🔄 Смена с суточного на обычный тариф: очищены daily поля')
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,
)
await db.refresh(subscription)
await db.refresh(user)
@@ -7314,7 +7194,7 @@ async def purchase_traffic_topup_endpoint(
# Добавляем трафик (add_subscription_traffic уже создаёт TrafficPurchase и обновляет все необходимые поля)
await add_subscription_traffic(db, subscription, payload.gb)
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
from app.database.crud.subscription import reactivate_subscription
await reactivate_subscription(db, subscription)
@@ -7323,6 +7203,9 @@ async def purchase_traffic_topup_endpoint(
try:
service = SubscriptionService()
await service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
await service.enable_remnawave_user(user.remnawave_uuid)
except Exception as e:
logger.error('Ошибка синхронизации с RemnaWave при докупке трафика', error=e)
@@ -7387,6 +7270,7 @@ async def toggle_daily_subscription_pause_endpoint(
was_disabled = subscription.status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
)
# System-DISABLED subs (is_daily_paused=False) должны идти по пути resume
+13 -4
View File
@@ -23,6 +23,7 @@ from app.database.crud.subscription import (
remove_subscription_squad,
replace_subscription,
)
from app.database.crud.user import get_user_by_id
from app.database.models import Subscription, SubscriptionStatus
from app.services.subscription_service import SubscriptionService
@@ -255,13 +256,17 @@ async def add_subscription_traffic_endpoint(
subscription = await _get_subscription(db, subscription_id)
subscription = await add_subscription_traffic(db, subscription, payload.gb)
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
await reactivate_subscription(db, subscription)
# Синхронизируем с RemnaWave
# Синхронизируем с RemnaWave и явно включаем пользователя на панели
service = SubscriptionService()
await service.update_remnawave_user(db, subscription)
user = await get_user_by_id(db, subscription.user_id)
if user and user.remnawave_uuid and subscription.status == 'active':
await service.enable_remnawave_user(user.remnawave_uuid)
subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription)
@@ -276,13 +281,17 @@ async def add_subscription_devices_endpoint(
subscription = await _get_subscription(db, subscription_id)
subscription = await add_subscription_devices(db, subscription, payload.devices)
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
await reactivate_subscription(db, subscription)
# Синхронизируем с RemnaWave
# Синхронизируем с RemnaWave и явно включаем пользователя на панели
service = SubscriptionService()
await service.update_remnawave_user(db, subscription)
user = await get_user_by_id(db, subscription.user_id)
if user and user.remnawave_uuid and subscription.status == 'active':
await service.enable_remnawave_user(user.remnawave_uuid)
subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription)
@@ -0,0 +1,36 @@
"""add show_in_gift to tariffs
Boolean flag to control tariff visibility in the gift section.
Defaults to True so all existing tariffs remain visible.
Revision ID: 0038
Revises: 0037
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0038'
down_revision: Union[str, None] = '0037'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_column(table: str, column: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return column in [c['name'] for c in inspector.get_columns(table)]
def upgrade() -> None:
if not _has_column('tariffs', 'show_in_gift'):
op.add_column(
'tariffs',
sa.Column('show_in_gift', sa.Boolean(), nullable=False, server_default=sa.text('true')),
)
def downgrade() -> None:
op.drop_column('tariffs', 'show_in_gift')
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.30.0"
version = "3.32.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
+25 -15
View File
@@ -20,6 +20,7 @@ os.environ.setdefault('BOT_TOKEN', 'test-token')
from app.config import settings
from app.database.models import PaymentMethod
from app.services.payment.cryptobot import CryptoBotPaymentMixin
from app.services.pricing_engine import PricingEngine
from app.services.subscription_renewal_service import (
SubscriptionRenewalPricing,
SubscriptionRenewalResult,
@@ -348,7 +349,9 @@ async def test_cryptobot_renewal_uses_pricing_snapshot(monkeypatch):
module = sys.modules['app.services.payment.cryptobot']
mixin = CryptoBotPaymentMixin()
subscription = types.SimpleNamespace(id=77, connected_squads=[], traffic_limit_gb=100, device_limit=5)
subscription = types.SimpleNamespace(
id=77, connected_squads=[], traffic_limit_gb=100, device_limit=5, tariff=None, tariff_id=None
)
user = types.SimpleNamespace(id=5, balance_kopeks=7000, subscription=subscription)
pricing_model = SubscriptionRenewalPricing(
@@ -385,9 +388,9 @@ async def test_cryptobot_renewal_uses_pricing_snapshot(monkeypatch):
)
async def fail_calculate(*args, **kwargs):
raise AssertionError('calculate_pricing should not be called when snapshot is present')
raise AssertionError('PricingEngine.calculate_renewal_price should not be called when snapshot is present')
monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fail_calculate)
monkeypatch.setattr(PricingEngine, 'calculate_renewal_price', fail_calculate)
captured: dict[str, Any] = {}
@@ -431,7 +434,9 @@ async def test_cryptobot_renewal_accepts_changed_pricing_without_snapshot(monkey
module = sys.modules['app.services.payment.cryptobot']
mixin = CryptoBotPaymentMixin()
subscription = types.SimpleNamespace(id=55, connected_squads=[], traffic_limit_gb=50, device_limit=3)
subscription = types.SimpleNamespace(
id=55, connected_squads=[], traffic_limit_gb=50, device_limit=3, tariff=None, tariff_id=None
)
user = types.SimpleNamespace(id=8, balance_kopeks=4000, subscription=subscription)
descriptor = build_payment_descriptor(
@@ -451,25 +456,26 @@ async def test_cryptobot_renewal_accepts_changed_pricing_without_snapshot(monkey
sys.modules, 'app.services.payment_service', types.SimpleNamespace(get_user_by_id=fake_get_user_by_id)
)
# Recalculated price is LOWER than descriptor — user benefits, should proceed
recalculated_pricing = SubscriptionRenewalPricing(
period_days=30,
period_id='days:30',
months=1,
base_original_total=5200,
discounted_total=5200,
final_total=5200,
base_original_total=4800,
discounted_total=4800,
final_total=4800,
promo_discount_value=0,
promo_discount_percent=0,
overall_discount_percent=0,
per_month=5200,
per_month=4800,
server_ids=[],
details={},
)
async def fake_calculate(db, u, sub, period):
async def fake_calculate(self, db, sub, period_days, *, user=None):
return recalculated_pricing
monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fake_calculate)
monkeypatch.setattr(PricingEngine, 'calculate_renewal_price', fake_calculate)
captured: dict[str, Any] = {}
@@ -499,8 +505,10 @@ async def test_cryptobot_renewal_accepts_changed_pricing_without_snapshot(monkey
)
assert result is True
assert captured['pricing'].final_total == 5000
assert captured['charge'] == 4000
# With C-4 fix: recalculated price (4800) < descriptor (5000) → use recalculated
assert captured['pricing'].final_total == 4800
# M-5 fix: required_balance = max(0, final_total - missing) = max(0, 4800 - 1000) = 3800
assert captured['charge'] == 3800
@pytest.mark.anyio('asyncio')
@@ -508,7 +516,9 @@ async def test_cryptobot_webhook_uses_inline_payload_when_db_missing(monkeypatch
module = sys.modules['app.services.payment.cryptobot']
mixin = CryptoBotPaymentMixin()
subscription = types.SimpleNamespace(id=91, connected_squads=[], traffic_limit_gb=80, device_limit=4)
subscription = types.SimpleNamespace(
id=91, connected_squads=[], traffic_limit_gb=80, device_limit=4, tariff=None, tariff_id=None
)
user = types.SimpleNamespace(id=21, balance_kopeks=6000, subscription=subscription)
pricing_model = SubscriptionRenewalPricing(
@@ -560,9 +570,9 @@ async def test_cryptobot_webhook_uses_inline_payload_when_db_missing(monkeypatch
)
async def fail_calculate(*args, **kwargs):
raise AssertionError('calculate_pricing should not be called')
raise AssertionError('PricingEngine.calculate_renewal_price should not be called')
monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fail_calculate)
monkeypatch.setattr(PricingEngine, 'calculate_renewal_price', fail_calculate)
captured: dict[str, Any] = {}
+970
View File
@@ -0,0 +1,970 @@
import itertools
import pytest
from app.services.pricing_engine import PricingEngine, RenewalPricing
def test_renewal_pricing_is_frozen():
p = RenewalPricing(
base_price=29000,
servers_price=5000,
traffic_price=0,
devices_price=0,
promo_group_discount=0,
promo_offer_discount=0,
final_total=34000,
period_days=30,
is_tariff_mode=False,
)
assert p.final_total == 34000
with pytest.raises(AttributeError):
p.final_total = 0
class TestApplyDiscount:
def test_basic_discount(self):
assert PricingEngine.apply_discount(10000, 20) == 8000
def test_zero_discount(self):
assert PricingEngine.apply_discount(10000, 0) == 10000
def test_full_discount(self):
assert PricingEngine.apply_discount(10000, 100) == 0
def test_negative_clamped(self):
assert PricingEngine.apply_discount(10000, -5) == 10000
def test_over_100_clamped(self):
assert PricingEngine.apply_discount(10000, 150) == 0
def test_integer_floor_division(self):
assert PricingEngine.apply_discount(99900, 30) == 69930
class TestStackedDiscounts:
def test_group_then_offer(self):
final, g_val, o_val = PricingEngine.apply_stacked_discounts(10000, 20, 10)
assert final == 7200
assert g_val == 2000
assert o_val == 800
def test_no_discounts(self):
final, g_val, o_val = PricingEngine.apply_stacked_discounts(10000, 0, 0)
assert final == 10000
assert g_val == 0
assert o_val == 0
def test_only_offer(self):
final, g_val, o_val = PricingEngine.apply_stacked_discounts(10000, 0, 15)
assert final == 8500
assert g_val == 0
assert o_val == 1500
def test_only_group(self):
result, gd, od = PricingEngine.apply_stacked_discounts(10000, 20, 0)
assert result == 8000
assert gd == 2000
assert od == 0
def test_both_100_percent(self):
result, gd, od = PricingEngine.apply_stacked_discounts(10000, 100, 100)
assert result == 0
assert gd == 10000
assert od == 0 # offer discount on 0 is 0
from unittest.mock import AsyncMock, MagicMock, patch
class TestPeriodDaysValidation:
@pytest.mark.asyncio
async def test_negative_period_days_raises(self):
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = None
subscription.tariff = None
with pytest.raises(ValueError, match='Invalid period_days'):
await engine.calculate_renewal_price(db, subscription, -1)
@pytest.mark.asyncio
async def test_zero_period_days_raises(self):
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = None
subscription.tariff = None
with pytest.raises(ValueError, match='Invalid period_days'):
await engine.calculate_renewal_price(db, subscription, 0)
@pytest.mark.asyncio
async def test_float_period_days_raises(self):
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = None
subscription.tariff = None
with pytest.raises(ValueError, match='Invalid period_days'):
await engine.calculate_renewal_price(db, subscription, 30.0)
_server_id_seq = itertools.count(1)
def _make_server(
price_kopeks=5000, is_available=True, is_full=False, allowed_promo_groups=None, server_id=None, squad_uuid=None
):
if server_id is None:
server_id = next(_server_id_seq)
server = MagicMock()
server.id = server_id
server.squad_uuid = squad_uuid
server.price_kopeks = price_kopeks
server.is_available = is_available
server.is_full = is_full
server.allowed_promo_groups = allowed_promo_groups or []
return server
class TestCalculateServersPrice:
@pytest.mark.asyncio
async def test_available_server(self):
engine = PricingEngine()
db = AsyncMock()
server = _make_server(price_kopeks=5000, squad_uuid='uuid-1')
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]):
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None)
assert total == 5000
assert len(details) == 1
assert details[0]['price'] == 5000
@pytest.mark.asyncio
async def test_unavailable_server_uses_real_price(self):
engine = PricingEngine()
db = AsyncMock()
server = _make_server(price_kopeks=7000, is_available=False, squad_uuid='uuid-1')
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]):
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None)
assert total == 7000 # NOT 0!
assert details[0]['status'] == 'unavailable'
@pytest.mark.asyncio
async def test_full_server_uses_real_price(self):
engine = PricingEngine()
db = AsyncMock()
server = _make_server(price_kopeks=3000, is_full=True, squad_uuid='uuid-1')
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]):
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None)
assert total == 3000 # NOT 0!
@pytest.mark.asyncio
async def test_server_not_found_zero_price(self):
engine = PricingEngine()
db = AsyncMock()
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[]):
total, details = await engine._calculate_servers_price(['uuid-orphan'], db, promo_group_id=None)
assert total == 0
assert details[0]['status'] == 'not_found'
@pytest.mark.asyncio
async def test_multiple_servers(self):
engine = PricingEngine()
db = AsyncMock()
s1 = _make_server(price_kopeks=5000, squad_uuid='uuid-1')
s2 = _make_server(price_kopeks=3000, is_available=False, squad_uuid='uuid-2')
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[s1, s2]):
total, details = await engine._calculate_servers_price(['uuid-1', 'uuid-2'], db, promo_group_id=None)
assert total == 8000
@pytest.mark.asyncio
async def test_server_ids_and_prices_alignment_with_not_found(self):
"""Verify server_ids and servers_individual_prices have same length when some servers are not found."""
engine = PricingEngine()
db = AsyncMock()
s1 = _make_server(price_kopeks=5000, server_id=10, squad_uuid='uuid-1')
s3 = _make_server(price_kopeks=3000, server_id=30, squad_uuid='uuid-3')
# uuid-orphan not in batch result — should be excluded from BOTH lists
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[s1, s3]):
total, details = await engine._calculate_servers_price(
['uuid-1', 'uuid-orphan', 'uuid-3'], db, promo_group_id=None
)
assert total == 8000 # 5000 + 0 + 3000
assert len(details) == 3
# Verify id fields
assert details[0]['id'] == 10
assert details[1]['id'] is None
assert details[2]['id'] == 30
@pytest.mark.asyncio
async def test_db_exception_path(self):
"""Verify batch DB exception returns price=0 and status=error for all UUIDs."""
engine = PricingEngine()
db = AsyncMock()
with patch('app.services.pricing_engine.get_server_squads_by_uuids', side_effect=RuntimeError('DB error')):
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None)
assert total == 0
assert details[0]['status'] == 'error'
assert details[0]['id'] is None
@pytest.mark.asyncio
async def test_empty_uuids_returns_empty(self):
"""Verify empty UUIDs list returns 0 total and empty details."""
engine = PricingEngine()
db = AsyncMock()
total, details = await engine._calculate_servers_price([], db, promo_group_id=None)
assert total == 0
assert details == []
class TestCalculateTrafficPrice:
def test_base_only(self):
engine = PricingEngine()
with patch('app.services.pricing_engine.settings') as ms:
ms.get_traffic_price.side_effect = lambda gb: {25: 3000, 50: 5000}.get(gb, 0)
price = engine._calculate_traffic_price(traffic_limit_gb=25, purchased_traffic_gb=0)
assert price == 3000
def test_purchased_separated(self):
engine = PricingEngine()
with patch('app.services.pricing_engine.settings') as ms:
ms.get_traffic_price.side_effect = lambda gb: {25: 3000, 100: 8000, 125: 12000}.get(gb, 0)
price = engine._calculate_traffic_price(traffic_limit_gb=125, purchased_traffic_gb=100)
assert price == 11000 # NOT 12000
def test_zero_traffic(self):
engine = PricingEngine()
with patch('app.services.pricing_engine.settings') as ms:
ms.get_traffic_price.return_value = 0
price = engine._calculate_traffic_price(traffic_limit_gb=0, purchased_traffic_gb=0)
assert price == 0
def test_purchased_exceeds_total(self):
engine = PricingEngine()
with patch('app.services.pricing_engine.settings') as ms:
ms.get_traffic_price.side_effect = lambda gb: {0: 0, 100: 8000}.get(gb, 0)
price = engine._calculate_traffic_price(traffic_limit_gb=80, purchased_traffic_gb=100)
assert price == 8000 # base_gb clamped to 0
class TestCalculateRenewalPriceTariffMode:
@pytest.mark.asyncio
async def test_tariff_basic(self):
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = 2
subscription.tariff = MagicMock()
subscription.tariff.period_prices = {'30': 19000}
subscription.tariff.device_limit = 2
subscription.tariff.device_price_kopeks = None
subscription.tariff.id = 2
subscription.device_limit = 2
subscription.connected_squads = []
subscription.traffic_limit_gb = 50
subscription.purchased_traffic_gb = 0
user = MagicMock()
user.promo_group = None
user.promo_offer_discount_percent = 0
user.promo_offer_expires_at = None
with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
):
ms.PRICE_PER_DEVICE = 5000
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
assert result.is_tariff_mode is True
assert result.final_total == 19000
@pytest.mark.asyncio
async def test_tariff_extra_devices(self):
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = 2
subscription.tariff = MagicMock()
subscription.tariff.period_prices = {'30': 19000}
subscription.tariff.device_limit = 2
subscription.tariff.device_price_kopeks = None
subscription.tariff.id = 2
subscription.device_limit = 4
subscription.connected_squads = []
subscription.traffic_limit_gb = 50
subscription.purchased_traffic_gb = 0
user = MagicMock()
user.promo_group = None
user.promo_offer_discount_percent = 0
user.promo_offer_expires_at = None
with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
):
ms.PRICE_PER_DEVICE = 5000
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
assert result.devices_price == 10000
assert result.final_total == 29000
@pytest.mark.asyncio
async def test_tariff_device_price_from_tariff(self):
"""When tariff has device_price_kopeks set, use it instead of settings."""
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = 2
subscription.tariff = MagicMock()
subscription.tariff.period_prices = {'30': 10000}
subscription.tariff.device_limit = 2
subscription.tariff.device_price_kopeks = 3000 # tariff-specific price
subscription.tariff.id = 2
subscription.device_limit = 4 # 2 extra devices
user = MagicMock()
user.promo_group = None
with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
):
ms.PRICE_PER_DEVICE = 5000 # should NOT be used
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
assert result.devices_price == 6000 # 2 extra × 3000 (tariff price)
assert result.final_total == 16000 # 10000 + 6000
@pytest.mark.asyncio
async def test_tariff_with_discounts(self):
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = 1
subscription.tariff = MagicMock()
subscription.tariff.period_prices = {'30': 20000}
subscription.tariff.device_limit = 1
subscription.tariff.device_price_kopeks = None
subscription.tariff.id = 1
subscription.device_limit = 1
promo_group = MagicMock()
promo_group.get_discount_percent.return_value = 10
user = MagicMock()
user.promo_group = promo_group
with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=5),
patch('app.services.pricing_engine.settings') as ms,
):
ms.PRICE_PER_DEVICE = 5000
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
assert result.base_price == 20000
assert result.promo_group_discount == 2000
# After group: 18000, then 5% off 18000 = 900
assert result.promo_offer_discount == 900
assert result.final_total == 17100
@pytest.mark.asyncio
async def test_tariff_missing_period_returns_zero_base(self):
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = 1
subscription.tariff = MagicMock()
subscription.tariff.period_prices = {'30': 19000}
subscription.tariff.device_limit = 1
subscription.tariff.device_price_kopeks = None
subscription.tariff.id = 1
subscription.device_limit = 1
user = MagicMock()
user.promo_group = None
with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
):
ms.PRICE_PER_DEVICE = 5000
result = await engine.calculate_renewal_price(db, subscription, 60, user=user)
assert result.base_price == 0
assert result.final_total == 0
@pytest.mark.asyncio
async def test_tariff_device_limit_below_tariff_included(self):
"""When subscription device_limit < tariff device_limit, extra_devices is 0 (not negative)."""
engine = PricingEngine()
db = AsyncMock()
tariff = MagicMock()
tariff.id = 1
tariff.period_prices = {'30': 10000}
tariff.device_price_kopeks = 5000
tariff.device_limit = 5
sub = MagicMock()
sub.tariff_id = 1
sub.tariff = tariff
sub.device_limit = 2 # less than tariff's 5
user = MagicMock()
user.promo_group = None
with patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0):
result = await engine.calculate_renewal_price(db, sub, 30, user=user)
assert result.devices_price == 0
assert result.final_total == 10000
assert result.breakdown.get('extra_devices') == 0
@pytest.mark.asyncio
async def test_tariff_user_none(self):
"""When user=None, no discounts are applied."""
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = 1
subscription.tariff = MagicMock()
subscription.tariff.period_prices = {'30': 20000}
subscription.tariff.device_limit = 1
subscription.tariff.device_price_kopeks = None
subscription.tariff.id = 1
subscription.device_limit = 1
with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
):
ms.PRICE_PER_DEVICE = 5000
result = await engine.calculate_renewal_price(db, subscription, 30, user=None)
assert result.final_total == 20000
assert result.promo_group_discount == 0
assert result.promo_offer_discount == 0
class TestCalculateRenewalPriceClassicMode:
@pytest.mark.asyncio
async def test_classic_all_components(self):
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = None
subscription.tariff = None
subscription.connected_squads = ['uuid-1']
subscription.traffic_limit_gb = 50
subscription.purchased_traffic_gb = 0
subscription.device_limit = 2
user = MagicMock()
user.promo_group = None
user.promo_group_id = None
user.promo_offer_discount_percent = 0
user.promo_offer_expires_at = None
server = _make_server(price_kopeks=5000, squad_uuid='uuid-1')
with (
patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]),
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 29000}),
patch('app.services.pricing_engine.PERIOD_PRICES', {30: 29000}),
):
ms.get_traffic_price.return_value = 3000
ms.PRICE_PER_DEVICE = 0
ms.DEFAULT_DEVICE_LIMIT = 2
ms.is_traffic_fixed.return_value = False
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
assert result.is_tariff_mode is False
assert result.base_price == 29000
assert result.servers_price == 5000
assert result.traffic_price == 3000
assert result.final_total == 37000
@pytest.mark.asyncio
async def test_classic_with_discounts(self):
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = None
subscription.tariff = None
subscription.connected_squads = []
subscription.traffic_limit_gb = 0
subscription.purchased_traffic_gb = 0
subscription.device_limit = 2
promo_group = MagicMock()
promo_group.id = 1
promo_group.get_discount_percent.return_value = 20
user = MagicMock()
user.promo_group = promo_group
user.promo_group_id = 1
user.promo_offer_discount_percent = 10
user.promo_offer_expires_at = None
with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=10),
patch('app.services.pricing_engine.settings') as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
patch('app.services.pricing_engine.PERIOD_PRICES', {30: 10000}),
):
ms.get_traffic_price.return_value = 0
ms.PRICE_PER_DEVICE = 0
ms.DEFAULT_DEVICE_LIMIT = 2
ms.is_traffic_fixed.return_value = False
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
assert result.final_total == 7200
assert result.promo_group_discount == 2000
assert result.promo_offer_discount == 800
@pytest.mark.asyncio
async def test_classic_fallback_to_period_prices(self):
"""When CLASSIC_PERIOD_PRICES has no entry, falls back to PERIOD_PRICES."""
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = None
subscription.tariff = None
subscription.connected_squads = []
subscription.traffic_limit_gb = 0
subscription.purchased_traffic_gb = 0
subscription.device_limit = 1
user = MagicMock()
user.promo_group = None
user.promo_group_id = None
with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {}),
patch('app.services.pricing_engine.PERIOD_PRICES', {30: 99000}),
):
ms.get_traffic_price.return_value = 0
ms.PRICE_PER_DEVICE = 0
ms.DEFAULT_DEVICE_LIMIT = 1
ms.is_traffic_fixed.return_value = False
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
assert result.base_price == 99000
assert result.final_total == 99000
@pytest.mark.asyncio
async def test_classic_extra_devices(self):
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = None
subscription.tariff = None
subscription.connected_squads = []
subscription.traffic_limit_gb = 0
subscription.purchased_traffic_gb = 0
subscription.device_limit = 5
user = MagicMock()
user.promo_group = None
user.promo_group_id = None
with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
):
ms.get_traffic_price.return_value = 0
ms.PRICE_PER_DEVICE = 3000
ms.DEFAULT_DEVICE_LIMIT = 2
ms.is_traffic_fixed.return_value = False
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
# 5 - 2 = 3 extra devices * 3000 = 9000
assert result.devices_price == 9000
assert result.final_total == 19000
@pytest.mark.asyncio
async def test_classic_breakdown_server_ids_and_prices_alignment(self):
"""Verify server_ids and servers_individual_prices have same length when orphaned UUIDs present."""
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = None
subscription.tariff = None
subscription.connected_squads = ['uuid-found', 'uuid-orphan', 'uuid-found2']
subscription.traffic_limit_gb = 0
subscription.purchased_traffic_gb = 0
subscription.device_limit = 1
user = MagicMock()
user.promo_group = None
user.promo_group_id = None
s1 = _make_server(price_kopeks=5000, server_id=10, squad_uuid='uuid-found')
s3 = _make_server(price_kopeks=3000, server_id=30, squad_uuid='uuid-found2')
with (
patch(
'app.services.pricing_engine.get_server_squads_by_uuids',
return_value=[s1, s3],
),
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
):
ms.get_traffic_price.return_value = 0
ms.PRICE_PER_DEVICE = 0
ms.DEFAULT_DEVICE_LIMIT = 1
ms.is_traffic_fixed.return_value = False
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
# Verify breakdown alignment — both lists must have same length
ids = result.breakdown['server_ids']
prices = result.breakdown['servers_individual_prices']
assert len(ids) == len(prices), f'server_ids({len(ids)}) != prices({len(prices)})'
assert ids == [10, 30]
assert prices == [5000, 3000]
# Total servers_price includes only found servers
assert result.servers_price == 8000
@pytest.mark.asyncio
async def test_classic_fixed_traffic_ignores_subscription_values(self):
"""When is_traffic_fixed() is True, use fixed limit and zero purchased."""
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = None
subscription.tariff = None
subscription.connected_squads = []
subscription.traffic_limit_gb = 999 # should be ignored
subscription.purchased_traffic_gb = 500 # should be ignored
subscription.device_limit = 1
user = MagicMock()
user.promo_group = None
user.promo_group_id = None
with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
):
ms.is_traffic_fixed.return_value = True
ms.get_fixed_traffic_limit.return_value = 50
ms.get_traffic_price.side_effect = lambda gb: {50: 4000}.get(gb, 0)
ms.PRICE_PER_DEVICE = 0
ms.DEFAULT_DEVICE_LIMIT = 1
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
assert result.traffic_price == 4000
assert result.breakdown['purchased_traffic_gb'] == 0
@pytest.mark.asyncio
async def test_classic_default_traffic_limit_when_none(self):
"""When subscription.traffic_limit_gb is None, use DEFAULT_TRAFFIC_LIMIT_GB."""
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = None
subscription.tariff = None
subscription.connected_squads = []
subscription.traffic_limit_gb = None # should fallback to default
subscription.purchased_traffic_gb = 0
subscription.device_limit = 1
user = MagicMock()
user.promo_group = None
user.promo_group_id = None
with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
):
ms.is_traffic_fixed.return_value = False
ms.DEFAULT_TRAFFIC_LIMIT_GB = 50
ms.get_traffic_price.side_effect = lambda gb: {50: 4000}.get(gb, 0)
ms.PRICE_PER_DEVICE = 0
ms.DEFAULT_DEVICE_LIMIT = 1
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
assert result.traffic_price == 4000
@pytest.mark.asyncio
async def test_classic_multi_month_period(self):
"""90-day period multiplies monthly prices by 3."""
engine = PricingEngine()
db = AsyncMock()
sub = MagicMock()
sub.tariff_id = None
sub.tariff = None
sub.connected_squads = ['uuid-s1']
sub.traffic_limit_gb = 50
sub.purchased_traffic_gb = 0
sub.device_limit = 1
user = MagicMock()
user.promo_group = None
user.promo_group_id = None
server = _make_server(price_kopeks=3000, squad_uuid='uuid-s1')
with (
patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]),
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {90: 27000}),
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
):
ms.DEFAULT_DEVICE_LIMIT = 1
ms.PRICE_PER_DEVICE = 5000
ms.get_traffic_price.return_value = 2000
ms.is_traffic_fixed.return_value = False
ms.DEFAULT_TRAFFIC_LIMIT_GB = 50
result = await engine.calculate_renewal_price(db, sub, 90, user=user)
assert result.period_days == 90
assert result.base_price == 27000
# Servers and traffic are monthly x 3 months
assert result.servers_price == 3000 * 3
assert result.traffic_price == 2000 * 3
assert result.devices_price == 0 # no extra devices
assert result.final_total == 27000 + 9000 + 6000
@pytest.mark.asyncio
async def test_classic_per_category_different_discounts(self):
"""Different discount percents per category (period=10%, servers=20%, traffic=30%, devices=0%)."""
engine = PricingEngine()
db = AsyncMock()
sub = MagicMock()
sub.tariff_id = None
sub.tariff = None
sub.connected_squads = ['uuid-s1']
sub.traffic_limit_gb = 100
sub.purchased_traffic_gb = 0
sub.device_limit = 3 # 2 extra devices
user = MagicMock()
promo_group = MagicMock()
def discount_by_category(category, period_days):
return {'period': 10, 'servers': 20, 'traffic': 30, 'devices': 0}[category]
promo_group.get_discount_percent = MagicMock(side_effect=discount_by_category)
user.promo_group = promo_group
user.promo_group_id = 1
server = _make_server(price_kopeks=6000, squad_uuid='uuid-s1')
with (
patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]),
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
):
ms.DEFAULT_DEVICE_LIMIT = 1
ms.PRICE_PER_DEVICE = 4000
ms.get_traffic_price.return_value = 5000
ms.is_traffic_fixed.return_value = False
ms.DEFAULT_TRAFFIC_LIMIT_GB = 100
result = await engine.calculate_renewal_price(db, sub, 30, user=user)
# period: 10000 * 10% = 1000 discount -> 9000
assert result.base_price == 9000
# servers: 6000 * 20% = 1200 discount -> 4800 per month x 1
assert result.servers_price == 4800
# traffic: 5000 * 30% = 1500 discount -> 3500 per month x 1
assert result.traffic_price == 3500
# devices: 2 extra x 4000 = 8000, 0% discount -> 8000
assert result.devices_price == 8000
# total group discount = 1000 + 1200 + 1500 + 0 = 3700
assert result.promo_group_discount == 3700
assert result.final_total == 9000 + 4800 + 3500 + 8000
@pytest.mark.asyncio
async def test_classic_user_none(self):
"""When user=None, no discounts are applied."""
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = None
subscription.tariff = None
subscription.connected_squads = []
subscription.traffic_limit_gb = 0
subscription.purchased_traffic_gb = 0
subscription.device_limit = 1
with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 15000}),
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
):
ms.get_traffic_price.return_value = 0
ms.PRICE_PER_DEVICE = 0
ms.DEFAULT_DEVICE_LIMIT = 1
ms.is_traffic_fixed.return_value = False
result = await engine.calculate_renewal_price(db, subscription, 30, user=None)
assert result.final_total == 15000
assert result.promo_group_discount == 0
assert result.promo_offer_discount == 0
class TestServerPromoGroupFiltering:
@pytest.mark.asyncio
async def test_server_not_allowed_for_promo_group(self):
"""Server with restricted promo groups still charges real price."""
engine = PricingEngine()
db = AsyncMock()
pg_mock = MagicMock()
pg_mock.id = 99
server = _make_server(price_kopeks=5000, squad_uuid='uuid-1', allowed_promo_groups=[pg_mock])
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]):
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=5)
assert total == 5000 # real price still charged
assert details[0]['status'] == 'not_allowed'
@pytest.mark.asyncio
async def test_server_empty_allowed_groups_is_open(self):
"""Server with empty allowed_promo_groups is available to all."""
engine = PricingEngine()
db = AsyncMock()
server = _make_server(price_kopeks=5000, squad_uuid='uuid-1', allowed_promo_groups=[])
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]):
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=5)
assert total == 5000
assert details[0]['status'] == 'available'
class TestFromPayloadRoundTrip:
def test_renewal_pricing_snapshot_roundtrip(self):
"""RenewalPricing serialized via asdict() is correctly restored by from_payload()."""
import dataclasses
from app.services.subscription_renewal_service import SubscriptionRenewalPricing
pricing = RenewalPricing(
base_price=29000,
servers_price=5000,
traffic_price=3000,
devices_price=0,
promo_group_discount=2000,
promo_offer_discount=800,
final_total=34200,
period_days=30,
is_tariff_mode=False,
breakdown={
'server_ids': [1, 2],
'servers_individual_prices': [5000, 3000],
'offer_discount_pct': 5,
},
)
payload = dataclasses.asdict(pricing)
restored = SubscriptionRenewalPricing.from_payload(payload)
assert restored.final_total == 34200
assert restored.period_days == 30
assert restored.promo_discount_value == 800 # mapped from promo_offer_discount
assert restored.server_ids == [1, 2]
assert restored.details.get('servers_individual_prices') == [5000, 3000]
assert restored.months == 1
assert restored.per_month == 34200
# ---------------------------------------------------------------------------
# Patch-target constants used in new tests below
# ---------------------------------------------------------------------------
SERVERS_BATCH_PATH = 'app.services.pricing_engine.get_server_squads_by_uuids'
SETTINGS_PATH = 'app.services.pricing_engine.settings'
class TestFromPayloadLegacyRoundTrip:
def test_legacy_to_payload_roundtrip(self):
"""Legacy SubscriptionRenewalPricing.to_payload() -> from_payload() preserves all fields."""
from app.services.subscription_renewal_service import SubscriptionRenewalPricing, build_renewal_period_id
original = SubscriptionRenewalPricing(
period_days=30,
period_id=build_renewal_period_id(30),
months=1,
base_original_total=15000,
discounted_total=12000,
final_total=10800,
promo_discount_value=1200,
promo_discount_percent=10,
overall_discount_percent=28,
per_month=10800,
server_ids=[1, 2, 3],
details={'servers_individual_prices': [5000, 3000, 2000]},
)
payload = original.to_payload()
restored = SubscriptionRenewalPricing.from_payload(payload)
assert restored.period_days == original.period_days
assert restored.period_id == original.period_id
assert restored.months == original.months
assert restored.base_original_total == original.base_original_total
assert restored.discounted_total == original.discounted_total
assert restored.final_total == original.final_total
assert restored.promo_discount_value == original.promo_discount_value
assert restored.promo_discount_percent == original.promo_discount_percent
assert restored.overall_discount_percent == original.overall_discount_percent
assert restored.per_month == original.per_month
assert restored.server_ids == original.server_ids
class TestOriginalPriceIdentity:
@pytest.mark.asyncio
async def test_tariff_mode_identity(self):
"""final_total + promo_group_discount + promo_offer_discount == undiscounted subtotal."""
engine = PricingEngine()
db = AsyncMock()
tariff = MagicMock()
tariff.id = 1
tariff.period_prices = {'30': 20000}
tariff.device_price_kopeks = 3000
tariff.device_limit = 1
sub = MagicMock()
sub.tariff_id = 1
sub.tariff = tariff
sub.device_limit = 3 # 2 extra
user = MagicMock()
promo_group = MagicMock()
promo_group.get_discount_percent = MagicMock(return_value=25)
user.promo_group = promo_group
with patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=15):
result = await engine.calculate_renewal_price(db, sub, 30, user=user)
subtotal = 20000 + 2 * 3000 # 26000
assert result.final_total + result.promo_group_discount + result.promo_offer_discount == subtotal
@pytest.mark.asyncio
async def test_classic_mode_identity(self):
"""final_total + promo_group_discount + promo_offer_discount == undiscounted total in classic mode."""
engine = PricingEngine()
db = AsyncMock()
sub = MagicMock()
sub.tariff_id = None
sub.tariff = None
sub.connected_squads = ['uuid-s1']
sub.traffic_limit_gb = 50
sub.purchased_traffic_gb = 0
sub.device_limit = 2 # 1 extra
user = MagicMock()
promo_group = MagicMock()
promo_group.get_discount_percent = MagicMock(return_value=20)
user.promo_group = promo_group
user.promo_group_id = 1
server = _make_server(price_kopeks=4000, squad_uuid='uuid-s1')
with (
patch(SERVERS_BATCH_PATH, return_value=[server]),
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=10),
patch(SETTINGS_PATH) as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
):
ms.PRICE_PER_DEVICE = 5000
ms.DEFAULT_DEVICE_LIMIT = 1
ms.get_traffic_price.return_value = 3000
ms.is_traffic_fixed.return_value = False
ms.DEFAULT_TRAFFIC_LIMIT_GB = 50
result = await engine.calculate_renewal_price(db, sub, 30, user=user)
# Reconstruct original undiscounted total
original = result.final_total + result.promo_group_discount + result.promo_offer_discount
# original should equal base_original + servers_original + traffic_original + devices_original
expected_original = 10000 + 4000 + 3000 + 5000 # 22000
assert original == expected_original
@pytest.mark.asyncio
async def test_original_total_property_tariff(self):
"""original_total property returns correct value."""
engine = PricingEngine()
db = AsyncMock()
tariff = MagicMock()
tariff.id = 1
tariff.period_prices = {'30': 20000}
tariff.device_price_kopeks = None
tariff.device_limit = 1
sub = MagicMock()
sub.tariff_id = 1
sub.tariff = tariff
sub.device_limit = 1
user = MagicMock()
promo_group = MagicMock()
promo_group.get_discount_percent = MagicMock(return_value=10)
user.promo_group = promo_group
with patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=5):
result = await engine.calculate_renewal_price(db, sub, 30, user=user)
assert result.original_total == 20000 # undiscounted subtotal
Generated
+1 -1
View File
@@ -1115,7 +1115,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.28.0"
version = "3.30.0"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },