Compare commits

...

73 Commits

Author SHA1 Message Date
Egor 484d2f7e34 Merge pull request #2740 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.32.3
2026-03-15 01:24:46 +03:00
github-actions[bot] 842fb697e6 chore(main): release 3.32.3 2026-03-14 22:24:27 +00:00
Egor 3ac3a92e26 Merge pull request #2739 from BEDOLAGA-DEV/dev
Dev
2026-03-15 01:24:05 +03:00
Fringg 7648707ca2 fix: campaign registration, revenue calculation, backup restore, autopay errors, referral links
- fix campaign registration not recorded when CHANNEL_IS_REQUIRED_SUB + SKIP_RULES_ACCEPT enabled (missing _apply_campaign_bonus_if_needed in required_sub_channel_check fast path)
- fix revenue calculation counting bonus-funded subscription payments as income (now deposits only via REAL_PAYMENT_METHODS)
- fix backup restore PendingRollbackError cascade on unique constraint violations (savepoint wrapping in _restore_table_records and _restore_users_without_referrals)
- fix AttributeError on message.text.strip() when users send media in referral code handlers
- suppress 'message is not modified' TelegramBadRequest in autopay toggle
- add bot_referral_link to referral API response with URL encoding
2026-03-15 01:13:50 +03:00
Egor 7e466ef464 Merge pull request #2736 from Legacyyy777/main
fix: implement case-insensitive email checks in authentication and user retrieval
2026-03-14 22:30:56 +03:00
Egor 28321df4d2 Merge pull request #2738 from SayonaraQ/pr/topup-cart-fix
fix(payment): prioritize saved cart after topup over expired auto-extend
2026-03-14 22:27:56 +03:00
Fringg 6adf70b2da fix: refresh CLASSIC_PERIOD_PRICES when admin changes PRICE_*_DAYS or SALES_MODE
CLASSIC_PERIOD_PRICES was built once at import time and never updated,
causing classic mode to always show hardcoded defaults instead of
admin-configured prices.
2026-03-14 22:24:08 +03:00
SayonaraQ 2d204275da Fix race payment cart 2026-03-14 20:11:47 +03:00
Legacyyy777 ebee8348ca fix: implement case-insensitive email checks in authentication and user retrieval
Updated email queries in authentication routes and user CRUD operations to be case-insensitive. This change ensures that email comparisons ignore case, improving user experience and preventing potential registration/login issues with differently cased emails.
2026-03-14 04:39:11 +05:00
c0mrade 06954c1711 Merge pull request #2735 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.32.2
2026-03-14 00:18:51 +03:00
github-actions[bot] 5e04e2a020 chore(main): release 3.32.2 2026-03-13 21:17:32 +00:00
c0mrade 08d69fb47f Merge pull request #2734 from BEDOLAGA-DEV/dev
Dev
2026-03-14 00:17:06 +03:00
c0mrade 3306e02902 fix: add nested selectinload and referrer eager loading to prevent MissingGreenlet
Added selectinload(UserPromoGroup.promo_group) nested under
user_promo_groups to prevent lazy-load in get_primary_promo_group().
Added selectinload(User.referrer) for format_referrer_info().
Broadened except clause in format_referrer_info as safety net.
2026-03-14 00:14:42 +03:00
c0mrade 14dceaa39f fix: silence PARTICIPANT_ID_INVALID error in channel subscription check
Handle PARTICIPANT_ID_INVALID same as 'user not found' — expected for
users who authenticated via Telegram Login Widget but never interacted
with the bot or channel directly.
2026-03-13 21:39:39 +03:00
c0mrade 5442f288d4 fix: add selectinload to user lock queries to prevent MissingGreenlet
lock_user_for_update, subtract_user_balance, and add_user_balance use
select(User).with_for_update().populate_existing which expires loaded
relationships. Added selectinload for subscription, user_promo_groups
and promo_group to prevent lazy-load in async context.
2026-03-13 21:39:31 +03:00
c0mrade 5bf4aeb31e Merge pull request #2733 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.32.1
2026-03-13 19:17:37 +03:00
github-actions[bot] 7356921eeb chore(main): release 3.32.1 2026-03-13 16:11:39 +00:00
c0mrade f24337fb41 Merge pull request #2732 from BEDOLAGA-DEV/dev
Dev
2026-03-13 19:11:14 +03:00
c0mrade 69a38dad25 fix: invalid ISO date format in node usage stats API call
datetime.now(UTC).isoformat() produces +00:00 suffix, appending Z
created invalid +00:00Z format causing RemnaWave API 500 errors.
Use .replace('+00:00', 'Z') instead of concatenation.
2026-03-13 18:58:36 +03:00
c0mrade aa3459b846 fix: platega webhook ID fallback for SBP and card payments
SBP sends `id`, cards send `transactionId`. Use fallback chain
to resolve transaction ID from all known field variants.
2026-03-13 18:41:14 +03:00
c0mrade 4d695be7d5 fix: resolve MissingGreenlet in switch_tariff endpoint
Use local subscription variable and db.refresh() to avoid lazy-load
of expired relationship after subtract_user_balance invalidates
the User identity map entry.
2026-03-13 18:30:32 +03:00
Egor b8fcbc7661 Merge pull request #2729 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.32.0
2026-03-13 06:19:11 +03:00
github-actions[bot] 96042782d9 chore(main): release 3.32.0 2026-03-13 03:18:41 +00:00
Egor a625eaae4f Merge pull request #2728 from BEDOLAGA-DEV/dev
Dev
2026-03-13 06:18:08 +03:00
Egor 869fe06831 Merge pull request #2727 from BEDOLAGA-DEV/main
w
2026-03-13 06:10:27 +03:00
Fringg a5fbd7400f fix: user deletion FK error + connected_squads None TypeError
Bug 1: DELETE /cabinet/admin/users/{id}/full failed with
"saved_payment_methods_user_id_fkey" FK violation.
Root cause: delete_user_account() didn't clean up saved_payment_methods
and riopay_payments before deleting the user row.
Fix: add DELETE for both tables before final user deletion.

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

subscription_purchase_service._apply_percentage_discount now delegates
to the shared apply_percentage_discount.

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

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

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

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

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

Removes unused imports: _apply_promo_offer_discount, validate_pricing_calculation.
2026-03-12 22:41:41 +03:00
Fringg ce82c2c009 refactor: migrate bot renewal display to PricingEngine
Replace manual per-component price calculation in handle_extend_subscription
with PricingEngine.calculate_renewal_price. This eliminates ~55 lines of
duplicated pricing logic (period, servers, devices, traffic calculations with
separate category-specific promo group discounts and months multiplication)
in favor of a single PricingEngine call per period. Also fixes double-application
of promo offer discount that existed in the old code path.
2026-03-12 22:37:55 +03:00
Fringg e6ebc6722d refactor: migrate try_auto_extend_expired to PricingEngine
Replace SubscriptionService.calculate_renewal_price() call in
try_auto_extend_expired_after_topup with PricingEngine.calculate_renewal_price().
Add structured log with pricing breakdown after calculation.
All downstream business logic (balance check, deduction, extend) unchanged.
2026-03-12 22:32:41 +03:00
Fringg 02e5401327 feat: implement calculate_renewal_price with tariff and classic modes
Add the main public method calculate_renewal_price to PricingEngine,
routing to _calculate_tariff_mode or _calculate_classic_mode based on
whether the subscription has a linked tariff. Both modes apply stacked
discounts (promo-group then promo-offer). Classic mode tries
CLASSIC_PERIOD_PRICES first, falling back to PERIOD_PRICES. Adds 8
new tests covering both modes, discounts, extra devices, and fallback.
2026-03-12 22:29:44 +03:00
Fringg c3bb63ffed feat: add CLASSIC_PERIOD_PRICES to config
Add a standalone dict that always reflects env PRICE_*_DAYS settings,
independent of tariffs mode. Unlike PERIOD_PRICES (which may use DB
tariff prices), CLASSIC_PERIOD_PRICES is the canonical source for
classic (non-tariff) subscription pricing. Includes refresh helper.
2026-03-12 22:29:37 +03:00
Fringg 88369eec50 feat: add _calculate_servers_price (fixed fallback) and _calculate_traffic_price
_calculate_servers_price ALWAYS uses real server.price_kopeks even when
is_available=False or is_full=True, fixing the silent zero-price bug.
_calculate_traffic_price separates base from purchased GB to prevent
purchased top-ups from inflating the tier lookup.
2026-03-12 22:20:48 +03:00
Fringg 83ca51cd5b feat: add RenewalPricing dataclass and PricingEngine discount methods 2026-03-12 22:18:08 +03:00
Egor f9dad615ee Merge pull request #2721 from FireWookie/feature/recurrent_method_inline
Отображение привязанных карт в разделе в боте
2026-03-12 20:28:31 +03:00
Fringg ba049ca017 fix: resolve merge conflict with dev (accept calc_device_limit_on_tariff_switch) 2026-03-12 20:27:48 +03:00
Fringg 585baaf63c fix: harden remnawave API error handling and YooKassa user cross-validation
- remnawave_api: use str() before .lower() to handle non-string API messages
- yookassa recovery: cross-validate user_telegram_id metadata against resolved
  user to prevent misattribution when legacy telegram_id fits in int32 range
2026-03-12 20:17:51 +03:00
Fringg 04197817fe fix: downgrade known-harmless RemnaWave 400s to warning level
"User already enabled" and "User already disabled" are expected
responses when reactivating subscriptions (e.g., traffic top-up on
active subscription with exhausted traffic). These should not
trigger error notifications in the admin chat.
2026-03-12 20:08:53 +03:00
Fringg b2ee6c766a fix: add missing settings import in admin_users tariff switch 2026-03-12 19:59:59 +03:00
Fringg d35ee58aa6 fix: harden YooKassa webhook recovery user lookup
- Reject user_id <= 0 early (corrupted metadata)
- Use `is None` checks instead of `or` to avoid falsy-value collisions
- Separate int parse from DB call in telegram_id fallback
- Move _INT32_MAX to module-level constant
2026-03-12 19:51:52 +03:00
Fringg 815a1d9136 fix: handle legacy telegram_id in YooKassa webhook recovery metadata
Legacy payments may store telegram_id (>int32) in metadata['user_id']
instead of internal User.id. The recovery path now:
- Detects values exceeding int32 range and queries by telegram_id
- Falls back to metadata['user_telegram_id'] if primary lookup fails
- Resolves to internal user.id before creating FK-linked payment record
2026-03-12 19:41:48 +03:00
Fringg b7775b72dc fix: guard rollback on commit flag, add flush to promo_offer_log
- subtract_user_balance: only rollback when commit=True, re-raise when
  commit=False so caller controls transaction lifecycle
- log_promo_offer_action: add db.flush() when commit=False to surface
  constraint errors immediately instead of deferring to caller's commit
2026-03-12 19:33:25 +03:00
Fringg ba54819f9c fix: atomicity refactor, review fixes, and DELETED recovery logging
- subtract_user_balance: add commit=False parameter for atomic balance+subscription ops
- extend_subscription: add commit=False parameter, propagate to clear_notifications
- wata_service: wire _MIN_EXPIRATION_MINUTES constant to actual usage
- admin_users: fix no-op ternary in sync_user_from_panel timezone normalization
- start.py: log warning when DELETED recovery zeros non-zero balance (3 locations)
- remnawave_service: preserve PromoCodeUse records and used_promocodes in force_cleanup
2026-03-12 19:26:36 +03:00
Fringg 266340aad1 fix: prevent balance loss on auto-purchase for DISABLED subscriptions and fix WATA expiration
- Block auto-purchase from stale cart when subscription is DISABLED
  (balance deduction is irreversible, Remnawave update would fail)
- Preserve user balance in force_cleanup_user_data (paid money must not be destroyed)
- Keep has_had_paid_subscription flag on cleanup (prevents promo code abuse)
- Add warning in sync_from_panel when local end_date is newer than panel
- Fix WATA payment expiration: enforce minimum 15 minutes to avoid
  hitting WATA API's exclusive lower bound (now + 10 min)
2026-03-12 19:09:16 +03:00
Fringg 8f434525eb feat: add LIMITED subscription status and preserve extra devices on tariff switch
- Add SubscriptionStatus.LIMITED for traffic-exhausted subscriptions
- Webhook user.limited now sets LIMITED directly instead of DISABLED
- Add LIMITED to reactivation, extend, resume, auto-purchase, contest eligibility
- Add traffic_exhausted error response in miniapp API
- Fix device_limit being overwritten on tariff switch in all code paths:
  admin change_tariff, user switch-tariff, miniapp, bot tariff_purchase,
  auto_purchase_service — now preserves extra purchased devices via
  calc_device_limit_on_tariff_switch() helper
- Fix truthiness checks on device_limit (0 is valid, use `is not None`)
2026-03-12 18:35:59 +03:00
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
78 changed files with 4036 additions and 2959 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.31.0"
".": "3.32.3"
}
+81
View File
@@ -1,5 +1,86 @@
# Changelog
## [3.32.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.2...v3.32.3) (2026-03-14)
### Bug Fixes
* campaign registration, revenue calculation, backup restore, autopay errors, referral links ([7648707](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7648707ca26d6cd2703b50b0fe8c4697e6155784))
* implement case-insensitive email checks in authentication and user retrieval ([7e466ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7e466ef464ce918d885bd6297d1e605a633fd43e))
* implement case-insensitive email checks in authentication and user retrieval ([ebee834](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebee8348ca338b9be5f044537e5e2b4740dc6441))
* **payment:** prioritize saved cart after topup over expired auto-extend ([28321df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28321df4d274269536efebcf3da870f2e7d07d90))
* refresh CLASSIC_PERIOD_PRICES when admin changes PRICE_*_DAYS or SALES_MODE ([6adf70b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6adf70b2da6e2250cc8e909dbb497b355302e72f))
## [3.32.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.1...v3.32.2) (2026-03-13)
### Bug Fixes
* add nested selectinload and referrer eager loading to prevent MissingGreenlet ([3306e02](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3306e029021c396e13774a205225beece4fbbcfb))
* add selectinload to user lock queries to prevent MissingGreenlet ([5442f28](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5442f288d4c6c3973dd92ac141172a9f0e53a28f))
* silence PARTICIPANT_ID_INVALID error in channel subscription check ([14dceaa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/14dceaa39ff9faa1c9205483653014a1c5ac73fb))
## [3.32.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.0...v3.32.1) (2026-03-13)
### Bug Fixes
* invalid ISO date format in node usage stats API call ([69a38da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69a38dad259bd05f4658e1014ce0bd73fc2e2ac5))
* platega webhook ID fallback for SBP and card payments ([aa3459b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa3459b8463ce0a54b7709aa3547b2337064fa26))
* resolve MissingGreenlet in switch_tariff endpoint ([4d695be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4d695be7d51adda40fa72c00c349fb0e1ec4acd2))
## [3.32.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.31.0...v3.32.0) (2026-03-13)
### New Features
* add _calculate_servers_price (fixed fallback) and _calculate_traffic_price ([88369ee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/88369eec5047e733d26d2450a74abd0d600b2e1b))
* add CLASSIC_PERIOD_PRICES to config ([c3bb63f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c3bb63ffed6e0b684c322aa51d70ab7e71c8eb6b))
* add LIMITED subscription status and preserve extra devices on tariff switch ([8f43452](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f434525eb14618e3c3e26261d443b1632c111bb))
* add RenewalPricing dataclass and PricingEngine discount methods ([83ca51c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/83ca51cd5b040e747c6db904dde0f3a5c59f480f))
* implement calculate_renewal_price with tariff and classic modes ([02e5401](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e5401327786c9dfe5ae7d4c89624c9455aa53e))
### Bug Fixes
* add missing settings import in admin_users tariff switch ([b2ee6c7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2ee6c766a1fb0c9a701684a6349b970d12f5e2e))
* add per-category discounts and months multiplier to classic mode ([1660b24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1660b24f9844374bbd156f9202a8e1550a6beb49))
* add period_days whitelist validation and type annotations ([18e2e78](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/18e2e7841a6d614263e7c87db5964916ec869a9d))
* address 6-agent review findings for PricingEngine ([c9f2dff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c9f2dffabf6369df360c5f9ad7a12c0415026310))
* address review findings from 5-agent audit ([08bea70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/08bea704ded78102dce29deac8da95c4e4b9d815))
* atomicity refactor, review fixes, and DELETED recovery logging ([ba54819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba54819f9cd7f60914dd472b68885683f435db4e))
* change None assignment to [] + add "or []" guards at all 5 call sites. ([a5fbd74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a5fbd7400f828824c5baa520bdaf06023b4caf70))
* downgrade known-harmless RemnaWave 400s to warning level ([0419781](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04197817fede058dc4688dce2f9877f0fc2a7f7f))
* guard rollback on commit flag, add flush to promo_offer_log ([b7775b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b7775b72dc7a1b3d18f179c2f247fe9f47023347))
* handle legacy telegram_id in YooKassa webhook recovery metadata ([815a1d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/815a1d9136f39b932d4b369aec9d67034d6785d9))
* harden remnawave API error handling and YooKassa user cross-validation ([585baaf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/585baaf63c9f535e5311a32085d0187d8c854001))
* harden YooKassa webhook recovery user lookup ([d35ee58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d35ee58aa6f3edc6a9e8ab43025569262acf64a2))
* payment providers — lock_user_for_update + commit=False atomicity ([b4ef52c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b4ef52caa4b324eded8e6c6cb715a09ad59140c1))
* prevent balance loss on auto-purchase for DISABLED subscriptions and fix WATA expiration ([266340a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/266340aad195995f208ed82fc11e0909d34898f4))
* pricing audit — display/charge parity, race conditions, balance locks ([ae99358](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ae99358ae9f35a25370ab127d98a0b630a08e3f2))
* resolve merge conflict with dev (accept calc_device_limit_on_tariff_switch) ([ba049ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba049ca017e004c25f8738f01b2d5f329a35bb5e))
* user deletion FK error + connected_squads None TypeError ([a5fbd74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a5fbd7400f828824c5baa520bdaf06023b4caf70))
### Refactoring
* add typed breakdowns + module-level singleton to PricingEngine ([b551def](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b551def3402e2bf762406fb0b374360958231bb3))
* extract shared formatting helpers into app/utils/formatting.py ([5e9a462](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5e9a462261e46ee649de481266a821fd6793bf2e))
* make finalize() accept both old and new pricing types ([3efa24b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3efa24bab3a2bd1d31103b134e502c10af8e41e1))
* migrate admin user price calculation to PricingEngine ([49c0f3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49c0f3fc10d27092961601cf7f6a780fb56885fa))
* migrate all callers to pricing_engine singleton + fix miniapp discount ([e24b911](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e24b911283bf4cbee7b18d3e49c935217e4a2863))
* migrate bot renewal display to PricingEngine ([ce82c2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ce82c2c00988542ac73dc9d2e811711ea9cefebe))
* migrate bot renewal execute to PricingEngine ([acf27a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acf27a102308d38676b2ccaef78016b56a80935d))
* migrate cabinet renewal display + execute to PricingEngine ([28fc36d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28fc36dca41b626430baf823274561269023ac59))
* migrate cart auto-purchase to PricingEngine (fresh calc) ([bd2e93a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd2e93a6a5076341b104f7dba2b7fc5fdb587e66))
* migrate menu.py renewal pricing to PricingEngine ([652b6da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/652b6dabde014d19075f13198dd06e5fb8bef380))
* migrate miniapp renewal display + execute to PricingEngine ([cb43aca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb43acab3194bbf9b6e2c04ca0254ba5b2571b2d))
* migrate recurrent and monitoring services to PricingEngine ([978f68e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/978f68e7be42b0faf92d9ac5bc0bbaa2022ac95b))
* migrate remaining callers to PricingEngine + cleanup dead CRUD ([75dbd2b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/75dbd2b4fcc8ab14ac44d915bf55b10406544bb1))
* migrate try_auto_extend_expired to PricingEngine ([e6ebc67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e6ebc6722d826d291156e6bff3bf86000b32b783))
* remove dead pricing code and fix miniapp classic mode ([c9a9816](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c9a9816daa15a4534a3990822543eeefe1a1631b))
* unify first-purchase discount algorithm with PricingEngine ([fe4e6ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe4e6acb5391d0797ea01281eeb2e2ea59a0070f))
## [3.31.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.30.0...v3.31.0) (2026-03-12)
+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.31.0" # x-release-please-version
ARG VERSION="v3.32.3" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+26 -16
View File
@@ -8,6 +8,7 @@ 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,
@@ -1122,26 +1123,18 @@ async def update_user_subscription(
)
# 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)
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
from app.config import settings
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
new_base = tariff.device_limit or 1
new_total = new_base + extra_devices
# Cap at new tariff's max_device_limit, falling back to global MAX_DEVICES_LIMIT
effective_max = tariff.max_device_limit or (
settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
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,
)
if effective_max and new_total > effective_max:
new_total = effective_max
subscription.device_limit = new_total
# Set squads from tariff
if tariff.allowed_squads:
subscription.connected_squads = tariff.allowed_squads
@@ -2523,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(),
+12 -8
View File
@@ -6,7 +6,7 @@ from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -721,8 +721,9 @@ async def register_email(
detail='Disposable email addresses are not allowed',
)
# Check if email already exists
existing_user = await db.execute(select(User).where(User.email == request.email))
# Check if email already exists (case-insensitive)
email_lower = (request.email or '').strip().lower()
existing_user = await db.execute(select(User).where(func.lower(User.email) == email_lower))
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -837,8 +838,9 @@ async def register_email_standalone(
detail='Disposable email addresses are not allowed',
)
# Проверить что email не занят
existing = await db.execute(select(User).where(User.email == request.email))
# Проверить что email не занят (без учёта регистра)
email_lower = (request.email or '').strip().lower()
existing = await db.execute(select(User).where(func.lower(User.email) == email_lower))
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -1096,8 +1098,9 @@ async def login_email(
# Check if this is a test email login
is_test_email = settings.is_test_email(request.email)
# Find user by email
result = await db.execute(select(User).where(User.email == request.email))
# Find user by email (case-insensitive)
email_lower = (request.email or '').strip().lower()
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
user = result.scalar_one_or_none()
if not user:
@@ -1317,7 +1320,8 @@ async def forgot_password(
detail='Too many requests',
headers={'Retry-After': '60'},
)
result = await db.execute(select(User).where(User.email == request.email))
email_lower = (request.email or '').strip().lower()
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
user = result.scalar_one_or_none()
# Always return success to prevent email enumeration
+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)
+9 -1
View File
@@ -91,12 +91,20 @@ async def get_referral_info(
referral_entitlement = max(0, total_earnings - withdrawn - pending)
available_balance = min(user.balance_kopeks, referral_entitlement)
# Build referral link
# Build referral links
referral_link = settings.get_referral_link(user.referral_code) if user.referral_code else ''
bot_username = settings.get_bot_username()
bot_referral_link = ''
if user.referral_code and bot_username:
from urllib.parse import quote
safe_code = quote(user.referral_code, safe='')
bot_referral_link = f'https://t.me/{bot_username}?start={safe_code}'
return ReferralInfoResponse(
referral_code=user.referral_code or '',
referral_link=referral_link,
bot_referral_link=bot_referral_link,
total_referrals=total_referrals,
active_referrals=active_referrals,
total_earnings_kopeks=total_earnings,
+132 -232
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,
}
@@ -4175,6 +4031,16 @@ async def switch_tariff(
detail='No active subscription with tariff',
)
# Lock subscription row to prevent concurrent tariff switches
locked_result = await db.execute(
select(Subscription)
.where(Subscription.id == user.subscription.id)
.with_for_update()
.execution_options(populate_existing=True)
)
subscription = locked_result.scalar_one()
user.subscription = subscription
# Use actual_status for correct status check (handles time-based expiration)
actual_status = user.subscription.actual_status
if actual_status == 'expired':
@@ -4337,6 +4203,7 @@ async def switch_tariff(
upgrade_cost,
description,
mark_as_paid_subscription=True,
commit=False,
)
if not success:
raise HTTPException(
@@ -4344,7 +4211,7 @@ async def switch_tariff(
detail='Failed to charge balance',
)
# Create transaction
# Create transaction (commit=False to keep FOR UPDATE lock held)
switch_transaction = await create_transaction(
db=db,
user_id=user.id,
@@ -4352,6 +4219,7 @@ async def switch_tariff(
amount_kopeks=upgrade_cost,
description=description,
payment_method=PaymentMethod.BALANCE,
commit=False,
)
else:
# Free switch (downgrade) — record in history
@@ -4362,54 +4230,85 @@ 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'
user.subscription.tariff_id = new_tariff.id
user.subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
user.subscription.device_limit = new_tariff.device_limit
user.subscription.connected_squads = new_tariff.allowed_squads or []
# Preserve extra purchased devices above the old tariff's base limit
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
# Re-load subscription to avoid MissingGreenlet from expired lazy relationship
# (subtract_user_balance re-selects User with populate_existing=True which expires relationships)
await db.refresh(user, ['subscription'])
subscription = user.subscription
subscription.tariff_id = new_tariff.id
subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
subscription.device_limit = calc_device_limit_on_tariff_switch(
current_device_limit=subscription.device_limit,
old_tariff_device_limit=current_tariff.device_limit if current_tariff else None,
new_tariff_device_limit=new_tariff.device_limit,
max_device_limit=new_tariff.max_device_limit,
)
subscription.connected_squads = new_tariff.allowed_squads or []
# Reset purchased traffic and delete TrafficPurchase records on tariff switch
from sqlalchemy import delete as sql_delete
from app.database.models import TrafficPurchase
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == user.subscription.id))
user.subscription.purchased_traffic_gb = 0
user.subscription.traffic_reset_at = None
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
user.subscription.traffic_used_gb = 0.0
subscription.traffic_used_gb = 0.0
if switching_to_daily:
# Switching TO daily - reset end_date to 1 day, set last_daily_charge_at
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
user.subscription.last_daily_charge_at = datetime.now(UTC)
user.subscription.is_daily_paused = False
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
subscription.last_daily_charge_at = datetime.now(UTC)
subscription.is_daily_paused = False
elif switching_from_daily:
user.subscription.end_date = datetime.now(UTC) + timedelta(days=new_period_days)
user.subscription.is_daily_paused = False
subscription.end_date = datetime.now(UTC) + timedelta(days=new_period_days)
subscription.is_daily_paused = False
user.subscription.updated_at = datetime.now(UTC)
subscription.updated_at = datetime.now(UTC)
await db.commit()
# Emit deferred side-effects after atomic commit
if upgrade_cost > 0 and switch_transaction:
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
switch_transaction,
amount_kopeks=upgrade_cost,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
payment_method=PaymentMethod.BALANCE,
)
# Sync with RemnaWave (optionally reset traffic based on admin setting)
should_reset_traffic = settings.RESET_TRAFFIC_ON_TARIFF_SWITCH
# Refresh subscription after commit (all objects are expired)
await db.refresh(subscription)
try:
subscription_service = SubscriptionService()
if getattr(user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(
db,
user.subscription,
subscription,
reset_traffic=should_reset_traffic,
reset_reason='смена тарифа',
)
else:
await subscription_service.create_remnawave_user(
db,
user.subscription,
subscription,
reset_traffic=should_reset_traffic,
reset_reason='смена тарифа',
)
@@ -4429,7 +4328,7 @@ async def switch_tariff(
logger.error('Failed to reset devices on tariff switch', error=e)
await db.refresh(user)
await db.refresh(user.subscription)
await db.refresh(subscription)
# Отправляем уведомление админам о смене тарифа
try:
@@ -4444,7 +4343,7 @@ async def switch_tariff(
await notification_service.send_subscription_purchase_notification(
db=db,
user=user,
subscription=user.subscription,
subscription=subscription,
transaction=switch_transaction if upgrade_cost > 0 else None,
period_days=remaining_days if remaining_days > 0 else new_period_days,
was_trial_conversion=False,
@@ -4460,7 +4359,7 @@ async def switch_tariff(
'success': True,
'message': f"Switched from '{old_tariff_name}' to '{new_tariff.name}'"
+ (' (devices reset)' if devices_reset else ''),
'subscription': _subscription_to_response(user.subscription),
'subscription': _subscription_to_response(subscription),
'old_tariff_name': old_tariff_name,
'new_tariff_id': new_tariff.id,
'new_tariff_name': new_tariff.name,
@@ -4516,6 +4415,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
@@ -10,6 +10,7 @@ class ReferralInfoResponse(BaseModel):
referral_code: str
referral_link: str
bot_referral_link: str = ''
total_referrals: int
active_referrals: int
total_earnings_kopeks: int
+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
+1
View File
@@ -22,6 +22,7 @@ class SubscriptionStatusEnum(StrEnum):
ACTIVE = 'active'
EXPIRED = 'expired'
DISABLED = 'disabled'
LIMITED = 'limited'
PENDING = 'pending'
+19
View File
@@ -2700,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}
+2 -1
View File
@@ -366,7 +366,8 @@ async def get_campaign_statistics(
first_payment_amount_by_user[user_id] = amount_value
first_payment_time_by_user[user_id] = created_at
total_revenue = deposits_total + subscription_payments_total
# Revenue = only real deposits (exclude bonus-funded subscription spending)
total_revenue = deposits_total
paid_user_ids = set(paid_users_from_transactions)
paid_user_ids.update(conversion_user_ids)
+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
+46 -259
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)
@@ -780,7 +811,11 @@ async def reactivate_subscription(db: AsyncSession, subscription: Subscription)
now = datetime.now(UTC)
# Тихо выходим если реактивация не нужна (уже активна или другой статус)
reactivatable_statuses = {SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value}
reactivatable_statuses = {
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
}
if subscription.status not in reactivatable_statuses:
return subscription
@@ -1376,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
@@ -1425,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)
@@ -2215,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
# Обновляем время последнего списания для корректного расчёта следующего
+89 -23
View File
@@ -410,6 +410,28 @@ 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.
Eagerly loads key relationships to avoid MissingGreenlet in async context.
"""
result = await db.execute(
select(User)
.where(User.id == user.id)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one()
async def add_user_balance(
db: AsyncSession,
user: User,
@@ -421,6 +443,22 @@ async def add_user_balance(
payment_method: PaymentMethod | None = None,
) -> bool:
try:
# Lock the user row to prevent concurrent balance race conditions
# Eagerly load key relationships to avoid MissingGreenlet in async context
locked_result = await db.execute(
select(User)
.where(User.id == user.id)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.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,17 +539,33 @@ 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
# Eagerly load key relationships to avoid MissingGreenlet in async context
locked_result = await db.execute(
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
select(User)
.where(User.id == user.id)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.with_for_update()
.execution_options(populate_existing=True)
)
user = locked_result.scalar_one()
@@ -572,8 +626,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 +633,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 +654,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:
@@ -1131,8 +1191,11 @@ async def create_user_by_email(
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
"""Get user by email address."""
result = await db.execute(select(User).where(User.email == email))
"""Get user by email address (case-insensitive)."""
if not email or not email.strip():
return None
email_lower = email.strip().lower()
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
return result.scalar_one_or_none()
@@ -1148,7 +1211,10 @@ async def is_email_taken(db: AsyncSession, email: str, exclude_user_id: int | No
Returns:
True if email is taken, False otherwise
"""
query = select(User.id).where(User.email == email)
if not email or not email.strip():
return False
email_lower = email.strip().lower()
query = select(User.id).where(func.lower(User.email) == email_lower)
if exclude_user_id:
query = query.where(User.id != exclude_user_id)
result = await db.execute(query)
+9 -1
View File
@@ -123,6 +123,7 @@ class SubscriptionStatus(Enum):
ACTIVE = 'active'
EXPIRED = 'expired'
DISABLED = 'disabled'
LIMITED = 'limited'
PENDING = 'pending'
@@ -1350,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'
@@ -1374,6 +1378,8 @@ class Subscription(Base):
return '🟢 Активна'
if actual_status == 'disabled':
return '⚫ Отключена'
if actual_status == 'limited':
return '⚠️ Трафик исчерпан'
if actual_status == 'trial':
return '🎯 Тестовая'
@@ -1391,6 +1397,8 @@ class Subscription(Base):
return '💎'
if actual_status == 'disabled':
return ''
if actual_status == 'limited':
return '⚠️'
if actual_status == 'trial':
return '🎁'
@@ -1439,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)
+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()
+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 = ' Пакеты не настроены'
+8 -9
View File
@@ -1210,7 +1210,7 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
end_date=format_datetime(subscription.end_date),
traffic=traffic_usage,
devices=subscription.device_limit,
countries=len(subscription.connected_squads),
countries=len(subscription.connected_squads or []),
)
)
else:
@@ -2712,7 +2712,7 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db:
text += f'• Статус: {sub_status}{sub_type}\n'
text += f'• Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ\n'
text += f'• Устройства: {subscription.device_limit}\n'
text += f'• Стран: {len(subscription.connected_squads)}\n'
text += f'• Стран: {len(subscription.connected_squads or [])}\n'
else:
text += '• Отсутствует\n'
@@ -4173,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:
@@ -4183,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
+5
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,
@@ -879,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,
+58
View File
@@ -302,6 +302,9 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte
language = data.get('language') or (getattr(user, 'language', None) if user else None) or DEFAULT_LANGUAGE
texts = get_texts(language)
if not message.text:
return False
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
potential_code = message.text.strip()
@@ -754,6 +757,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
@@ -1179,6 +1189,10 @@ async def process_referral_code_input(message: types.Message, state: FSMContext,
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
if not message.text:
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
return
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
code = message.text.strip()
@@ -1344,6 +1358,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 +1660,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 +1925,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', '⏳ Ожидает активации')
@@ -2317,6 +2348,33 @@ async def required_sub_channel_check(
except Exception as e:
logger.error('Ошибка при обработке реферальной регистрации', error=e)
# Применяем бонус рекламной кампании (record_campaign_registration)
campaign_message = await _apply_campaign_bonus_if_needed(db, user, state_data, texts)
try:
await db.refresh(user)
except Exception as refresh_error:
logger.error(
'Ошибка обновления данных пользователя после бонуса кампании',
telegram_id=user.telegram_id,
refresh_error=refresh_error,
)
try:
await db.refresh(user, ['subscription'])
except Exception as refresh_sub_error:
logger.error(
'Ошибка обновления подписки после бонуса кампании',
telegram_id=user.telegram_id,
refresh_sub_error=refresh_sub_error,
)
if campaign_message:
try:
await bot.send_message(
chat_id=query.from_user.id,
text=campaign_message,
)
except Exception as e:
logger.error('Ошибка отправки сообщения о бонусе кампании', error=e)
# Показываем главное меню после создания пользователя
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
+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',
+98 -1
View File
@@ -1,15 +1,23 @@
from aiogram import types
from aiogram.exceptions import TelegramBadRequest
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,
)
@@ -107,7 +115,13 @@ async def toggle_autopay(callback: types.CallbackQuery, db_user: User, db: Async
status = texts.t('AUTOPAY_STATUS_ENABLED', 'включен') if enable else texts.t('AUTOPAY_STATUS_DISABLED', 'выключен')
await callback.answer(texts.t('AUTOPAY_TOGGLE_SUCCESS', '✅ Автоплатеж {status}!').format(status=status))
await handle_autopay_menu(callback, db_user, db)
try:
await handle_autopay_menu(callback, db_user, db)
except TelegramBadRequest as e:
if 'message is not modified' in str(e):
pass
else:
raise
async def show_autopay_days(callback: types.CallbackQuery, db_user: User):
@@ -134,6 +148,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
):
+1 -1
View File
@@ -473,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 '⌛ Выключен',
+115 -358
View File
@@ -97,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,
@@ -118,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,
@@ -209,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 = '⏸️'
@@ -1591,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 = {}
@@ -1599,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,
}
@@ -1724,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)}',
]
@@ -1766,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)
@@ -1783,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:
@@ -1956,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,
}
@@ -1975,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()
@@ -3152,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,
@@ -3216,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
# При возобновлении проверяем баланс
@@ -4548,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
+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(
+28 -6
View File
@@ -1105,9 +1105,20 @@ class BackupService:
existing = existing_user.scalar_one_or_none()
if existing:
for key, value in processed_data.items():
if key != 'id':
setattr(existing, key, value)
try:
async with db.begin_nested():
for key, value in processed_data.items():
if key != 'id':
setattr(existing, key, value)
await db.flush()
except IntegrityError:
db.expire(existing)
logger.warning(
'Конфликт уникального ключа при обновлении пользователя, пропускаем',
user_id=processed_data.get('id'),
telegram_id=processed_data.get('telegram_id'),
)
continue
else:
instance = User(**processed_data)
try:
@@ -1376,9 +1387,20 @@ class BackupService:
existing = existing_record.scalar_one_or_none()
if existing:
for key, value in processed_data.items():
if key not in pk_cols:
setattr(existing, key, value)
try:
async with db.begin_nested():
for key, value in processed_data.items():
if key not in pk_cols:
setattr(existing, key, value)
await db.flush()
except IntegrityError:
db.expire(existing)
logger.warning(
'Конфликт уникального ключа при обновлении записи, пропускаем',
table_name=table_name,
pk={col: processed_data.get(col) for col in pk_cols},
)
continue
else:
instance = model(**processed_data)
try:
+3 -2
View File
@@ -274,8 +274,9 @@ class ChannelSubscriptionService:
)
return False # Fail-closed -- bot cannot verify membership
except TelegramBadRequest as e:
if 'user not found' in str(e).lower():
return False # User never interacted with bot in that context
err_msg = str(e).lower()
if 'user not found' in err_msg or 'participant_id_invalid' in err_msg:
return False # User never interacted with bot/channel
logger.error('Bad request checking channel', channel_id=channel_id, error=str(e))
return False # Fail-closed
except TelegramNetworkError:
+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(
+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(
'Ошибка расчёта стоимости автопродления, пропускаем',
+7 -24
View File
@@ -939,8 +939,7 @@ class PartnerStatsService:
registrations_dict = {str(row.date): int(row.count) for row in registrations_by_day.all()}
# --- Daily revenue (DAILY_STATS_DAYS days) ---
# Revenue = real deposits (positive) + abs(subscription_payments) (stored negative)
# Exclude promo/bonus deposits (payment_method IS NULL) from revenue
# Revenue = real deposits only (exclude bonus/promo balance spending on subscriptions)
revenue_amount_expr = func.coalesce(
func.sum(
case(
@@ -951,10 +950,6 @@ class PartnerStatsService:
),
Transaction.amount_kopeks,
),
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
func.abs(Transaction.amount_kopeks),
),
else_=0,
)
),
@@ -971,12 +966,8 @@ class PartnerStatsService:
Transaction.user_id.in_(campaign_user_ids_sq),
Transaction.is_completed.is_(True),
Transaction.created_at >= start_date,
Transaction.type.in_(
[
TransactionType.DEPOSIT.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
]
),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
)
)
.group_by(func.date(Transaction.created_at))
@@ -1027,12 +1018,8 @@ class PartnerStatsService:
Transaction.user_id.in_(campaign_user_ids_sq),
Transaction.is_completed.is_(True),
Transaction.created_at >= week_ago,
Transaction.type.in_(
[
TransactionType.DEPOSIT.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
]
),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
)
)
)
@@ -1046,12 +1033,8 @@ class PartnerStatsService:
Transaction.is_completed.is_(True),
Transaction.created_at >= previous_start,
Transaction.created_at < week_ago,
Transaction.type.in_(
[
TransactionType.DEPOSIT.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
]
),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
)
)
)
+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
+106 -99
View File
@@ -325,7 +325,111 @@ async def send_cart_notification_after_topup(
exc_info=True,
)
# Try to auto-extend expired subscription (works without cart)
cart_data = await user_cart_service.get_user_cart(user.id)
# В приоритете всегда сохраненная корзина: она отражает явный выбор пользователя
# (период/тариф/сумма). Автопродление expired — только когда корзины нет.
if cart_data:
cart_total = cart_data.get('total_price', 0)
if not cart_total:
logger.warning(
'Сохраненная корзина найдена, но total_price отсутствует или некорректен',
user_id=user.id,
cart_total=cart_total,
)
return False
# Try auto-purchase first
auto_purchase_success = False
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=bot)
except Exception as auto_error:
logger.error(
'Ошибка автоматической покупки подписки для пользователя',
user_id=user.id,
auto_error=auto_error,
exc_info=True,
)
if auto_purchase_success:
return False
if not bot or not getattr(user, 'telegram_id', None):
return False
# Refresh balance from DB to account for any changes during auto-purchase attempt
refreshed_user = await get_user_by_id(db, user.id)
balance = getattr(refreshed_user or user, 'balance_kopeks', 0)
texts = get_texts(getattr(user, 'language', 'ru'))
# Build message based on whether balance is sufficient
fmt = settings.format_price
cart_total_formatted = fmt(cart_total)
if balance >= cart_total:
template = texts.get('BALANCE_TOPPED_UP_CART_SUFFICIENT', '')
message_text = template.format(
amount=fmt(amount_kopeks),
balance=fmt(balance),
cart_total=cart_total_formatted,
total_amount=cart_total_formatted,
)
else:
missing = cart_total - balance
template = texts.get('BALANCE_TOPPED_UP_CART_INSUFFICIENT', '')
message_text = template.format(
amount=fmt(amount_kopeks),
balance=fmt(balance),
cart_total=cart_total_formatted,
total_amount=cart_total_formatted,
missing=fmt(missing),
)
if not message_text:
logger.warning('Missing cart notification template', language=getattr(user, 'language', 'ru'))
return False
sent = False
try:
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.get('RETURN_TO_SUBSCRIPTION_CHECKOUT', '⬅️ Checkout'),
callback_data='return_to_saved_cart',
)
],
[
types.InlineKeyboardButton(
text=texts.get('MY_BALANCE_BUTTON', '💰 Balance'),
callback_data='menu_balance',
)
],
[
types.InlineKeyboardButton(
text=texts.get('MAIN_MENU_BUTTON', '🏠 Menu'),
callback_data='back_to_menu',
)
],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=message_text,
reply_markup=keyboard,
parse_mode='HTML',
)
sent = True
logger.info('Sent cart notification to user', user_id=user.id)
except Exception as send_error:
logger.error(
'Failed to send cart notification to user',
user_id=user.id,
error=send_error,
)
return sent
# Try to auto-extend expired subscription only when there is no saved cart.
try:
auto_extended = await try_auto_extend_expired_after_topup(db, user, bot=bot)
if auto_extended:
@@ -338,104 +442,7 @@ async def send_cart_notification_after_topup(
exc_info=True,
)
cart_data = await user_cart_service.get_user_cart(user.id)
if not cart_data:
return False
cart_total = cart_data.get('total_price', 0)
if not cart_total:
return False
# Try auto-purchase first
auto_purchase_success = False
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=bot)
except Exception as auto_error:
logger.error(
'Ошибка автоматической покупки подписки для пользователя',
user_id=user.id,
auto_error=auto_error,
exc_info=True,
)
if auto_purchase_success:
return False
if not bot or not getattr(user, 'telegram_id', None):
return False
# Refresh balance from DB to account for any changes during auto-purchase attempt
refreshed_user = await get_user_by_id(db, user.id)
balance = getattr(refreshed_user or user, 'balance_kopeks', 0)
texts = get_texts(getattr(user, 'language', 'ru'))
# Build message based on whether balance is sufficient
fmt = settings.format_price
cart_total_formatted = fmt(cart_total)
if balance >= cart_total:
template = texts.get('BALANCE_TOPPED_UP_CART_SUFFICIENT', '')
message_text = template.format(
amount=fmt(amount_kopeks),
balance=fmt(balance),
cart_total=cart_total_formatted,
total_amount=cart_total_formatted,
)
else:
missing = cart_total - balance
template = texts.get('BALANCE_TOPPED_UP_CART_INSUFFICIENT', '')
message_text = template.format(
amount=fmt(amount_kopeks),
balance=fmt(balance),
cart_total=cart_total_formatted,
total_amount=cart_total_formatted,
missing=fmt(missing),
)
if not message_text:
logger.warning('Missing cart notification template', language=getattr(user, 'language', 'ru'))
return False
sent = False
try:
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.get('RETURN_TO_SUBSCRIPTION_CHECKOUT', '⬅️ Checkout'),
callback_data='return_to_saved_cart',
)
],
[
types.InlineKeyboardButton(
text=texts.get('MY_BALANCE_BUTTON', '💰 Balance'),
callback_data='menu_balance',
)
],
[
types.InlineKeyboardButton(
text=texts.get('MAIN_MENU_BUTTON', '🏠 Menu'),
callback_data='back_to_menu',
)
],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=message_text,
reply_markup=keyboard,
parse_mode='HTML',
)
sent = True
logger.info('Sent cart notification to user', user_id=user.id)
except Exception as send_error:
logger.error(
'Failed to send cart notification to user',
user_id=user.id,
error=send_error,
)
return sent
return False
# ---------------------------------------------------------------------------
+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,
+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(
'Ошибка расчёта стоимости для рекуррентного платежа',
+18 -10
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:
@@ -2321,8 +2323,8 @@ class RemnaWaveService:
async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> list[dict[str, Any]]:
try:
async with self.get_api_client() as api:
start_str = start_date.isoformat() + 'Z'
end_str = end_date.isoformat() + 'Z'
start_str = start_date.isoformat().replace('+00:00', 'Z')
end_str = end_date.isoformat().replace('+00:00', 'Z')
params = {'start': start_str, 'end': end_str}
@@ -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
+263 -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),
@@ -1513,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),
@@ -1574,6 +1612,30 @@ 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/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
@@ -1739,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: ошибка расчёта стоимости',
@@ -1757,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: некорректная стоимость',
@@ -1803,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:
@@ -1855,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
@@ -2016,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
@@ -2109,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(
@@ -2283,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 секунд,
@@ -2302,8 +2455,8 @@ async def auto_purchase_saved_cart_after_topup(
format_user_id=_format_user_id(user),
total_seconds=(datetime.now(UTC) - last_tx.created_at).total_seconds(),
)
# Очищаем корзину чтобы не срабатывало повторно
await user_cart_service.delete_user_cart(user.id)
# Корзину не очищаем: транзакция могла быть из другого потока
# (например, фоновое автопродление), чтобы не потерять явный выбор пользователя.
return False
except Exception as check_error:
logger.warning(
+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
+4
View File
@@ -11,6 +11,7 @@ from app.config import (
ENV_OVERRIDE_KEYS,
Settings,
clear_db_period_prices,
refresh_classic_period_prices,
refresh_period_prices,
refresh_traffic_prices,
settings,
@@ -1517,6 +1518,7 @@ class BotConfigurationService:
# т.к. ensure_tariffs_synced мог загрузить тарифные цены до того как
# SALES_MODE=classic был применён из system_settings
refresh_period_prices()
refresh_classic_period_prices()
@classmethod
async def reload(cls) -> None:
@@ -1673,6 +1675,7 @@ class BotConfigurationService:
if settings.is_classic_mode():
clear_db_period_prices()
refresh_period_prices()
refresh_classic_period_prices()
elif key in {
'PRICE_14_DAYS',
'PRICE_30_DAYS',
@@ -1682,6 +1685,7 @@ class BotConfigurationService:
'PRICE_360_DAYS',
}:
refresh_period_prices()
refresh_classic_period_prices()
elif key.startswith('PRICE_TRAFFIC_') or key == 'TRAFFIC_PACKAGES_CONFIG':
refresh_traffic_prices()
elif key in {'REMNAWAVE_AUTO_SYNC_ENABLED', 'REMNAWAVE_AUTO_SYNC_TIMES'}:
+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)
+5 -2
View File
@@ -24,6 +24,8 @@ def format_referrer_info(user: User) -> str:
try:
# Проверяем, является ли referrer обычным объектом или InstrumentedList
# getattr default does NOT catch MissingGreenlet (not an AttributeError),
# so we wrap in try/except to handle lazy-load failures in async context.
referrer = getattr(user, 'referrer', None)
# Если referrer это InstrumentedList или None, то возвращаем информацию по ID
@@ -39,8 +41,9 @@ def format_referrer_info(user: User) -> str:
return f'ID {referrer_telegram_id or referred_by_id}'
except (AttributeError, TypeError):
# Если возникла ошибка при обращении к атрибутам, просто возвращаем ID
except Exception:
# MissingGreenlet is not a subclass of AttributeError/TypeError,
# so we must catch broadly to handle lazy-load failures in async context.
return f'ID {referred_by_id} (ошибка загрузки)'
+200 -318
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import dataclasses
import math
import re
from collections.abc import Collection
@@ -4497,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
@@ -4815,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',
@@ -5217,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(
@@ -5238,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:
@@ -5501,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)
@@ -6619,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
@@ -6632,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:
@@ -6646,8 +6488,6 @@ async def purchase_tariff_endpoint(
},
)
subscription = getattr(user, 'subscription', None)
# Списываем баланс
if is_daily_tariff:
description = f"Активация суточного тарифа '{tariff.name}' (первый день)"
@@ -6660,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:
@@ -6691,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,
@@ -6698,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:
@@ -6783,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(
@@ -6959,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,
@@ -7045,6 +6900,7 @@ async def switch_tariff_endpoint(
upgrade_cost,
description,
mark_as_paid_subscription=True,
commit=False,
)
if not success:
raise HTTPException(
@@ -7053,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,
)
# Получаем список серверов из тарифа
@@ -7082,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
@@ -7125,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)
@@ -7389,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
+4 -2
View File
@@ -705,8 +705,10 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
if success:
return JSONResponse({'status': 'ok'})
transaction_id = payload.get('transactionId', 'unknown')
logger.error('Platega webhook processing failed: transactionId', transaction_id=transaction_id)
transaction_id = (
payload.get('id') or payload.get('transactionId') or payload.get('transaction_id') or 'unknown'
)
logger.error('Platega webhook processing failed', transaction_id=transaction_id)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.31.0"
version = "3.32.3"
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.29.0"
version = "3.32.2"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },