Compare commits

...

141 Commits

Author SHA1 Message Date
Egor d6442b87df Merge pull request #2926 from BEDOLAGA-DEV/dev
docs: add Antilopay/Etoplatezhi/Jupiter/Donut/Lava + Apple IAP to pro…
2026-05-04 21:22:17 +03:00
Fringg 31e3ccd24c docs: add Antilopay/Etoplatezhi/Jupiter/Donut/Lava + Apple IAP to provider list
- Bump provider counter 18 → 24+ in Features and Documentation sections
- Add 7 new rows to providers table:
  - Antilopay (RSA signing)
  - Etoplatezhi
  - Jupiter (FPGate P2P) — partner via @k_juppiter
  - Donut (Donut P2P) — partner via @donut_payment
  - Lava Business
  - Apple In-App Purchase
- Add 2 partner cards (Jupiter, Donut) following the Platega/PayPear pattern
2026-05-04 21:20:44 +03:00
Egor 3d4b7b4582 Merge pull request #2925 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.54.0
2026-05-04 20:58:57 +03:00
github-actions[bot] cedf4922fd chore(main): release 3.54.0 2026-05-04 17:57:52 +00:00
Egor 491f09d547 Merge pull request #2924 from BEDOLAGA-DEV/dev
Dev
2026-05-04 20:57:17 +03:00
Fringg 0d0646770d fix(tests): clean up pre-existing ruff lint warnings in apple_iap tests
- Remove unused unittest.mock.patch import (F401)
- Mark hardcoded /tmp/test.p8 path with noqa S108 (only used for
  is_apple_iap_enabled check, no file actually accessed)
- Replace pytest.raises(Exception) with pytest.raises(ValidationError)
  for Pydantic schema validation tests (B017)
2026-05-04 20:50:29 +03:00
Fringg 17732a0370 style: apply ruff format to payment integrations
CI ruff format --check failed on 13 files. Applied ruff format to bring
them in line with project formatting (line wrapping, trailing commas,
quote consistency). No functional changes.
2026-05-04 20:47:48 +03:00
Fringg cd8be32671 fix: register all providers in payment search and verification services
The admin Payments page filter and pending payments tab were missing newer
providers (paypear, rollypay, aurapay, etoplatezhi, antilopay, jupiter, donut,
lava) because they were never registered in the search/verification registries.
Customer payments via these providers were invisible in admin filtering.

payment_search_service.py:
- Add 8 _search_<provider> functions matching the existing pattern
- Register them in _PROVIDER_SEARCH_MAP (now 22 methods total)
- Filter dropdown and stats.by_method now include all providers

payment_verification_service.py:
- Add 8 _is_<provider>_pending and _fetch_<provider>_payments functions
- Wire them into list_recent_pending_payments and get_payment_record
- Add display name and is_enabled dispatch branches for all 8
- Register paypear / rollypay / aurapay in SUPPORTED_MANUAL_CHECK_METHODS and
  SUPPORTED_AUTO_CHECK_METHODS (they have full API+DB sync via check_*)
- Wire them into run_manual_check
- etoplatezhi / antilopay / jupiter / donut / lava remain webhook-driven and
  appear in pending tab without manual-check button (no fake API sync)
2026-05-04 20:43:47 +03:00
Fringg afea054c8f feat: integrate Lava Business payment provider
- Lava Business via gate.lava.ru (HMAC-SHA256 signed JSON requests)
- Sub-methods: card and SBP via includeService filter
- Webhook signature verified from raw bytes with secret_key_2
- Sticky terminal-status guard (success after amount_mismatch escalates to ERROR)
- Order ID with full uuid4 hex (128-bit entropy)
- Cross-row contamination guard: order_id assertion on invoice_id fallback
- Warning when hook URL cannot be derived from webhook/web_api/cabinet bases
- Explicit failure when Lava response lacks payment_url (no orphan rows)
- Adds LAVA settings category, /lava-webhook endpoint, cabinet topup branch
- Mirrors existing Antilopay/Jupiter/Donut mixin pattern
2026-05-04 20:14:31 +03:00
Fringg f321ded9c0 feat: integrate Jupiter (FPGate P2P) and Donut payment providers
- Jupiter: SBP via app.juppiter.tech (FPGate P2P v2.1)
- Donut: CARD/SBP/SBP_QR via gw.donut.business (Donut P2P)
- HMAC-SHA256 signing verified against spec reference vectors
- Sticky terminal-status guard in callback (amount_mismatch/declined/cancelled
  cannot be re-credited by replayed webhook)
- Mirrors existing Antilopay/Etoplatezhi mixin pattern: service, mixin, CRUD,
  Alembic migration, handlers, keyboards, webhook, cabinet route, status mapping
- Adds JUPITER and DONUT settings categories with title/description/prefix
- Backfills missing ANTILOPAY and ETOPLATEZHI category metadata
2026-05-04 19:36:22 +03:00
Fringg 3fce64858c fix: add pycryptodome dependency for Antilopay RSA signing 2026-05-04 17:31:06 +03:00
Fringg 1ab1ff90bf feat: add subscription reissue with 15-min cooldown
- Add revoke handler for classic and multi-tariff modes with 2-step
  confirmation dialog and TOCTOU-safe cooldown enforcement
- Add cabinet API endpoint POST /subscription/revoke with 429 + Retry-After
  for cooldown, IDOR protection via resolve_subscription
- Add last_revoke_at column to subscriptions (Alembic migration 0071)
- Add SUBSCRIPTION_REVOKE_ENABLED and COOLDOWN_SECONDS config settings
- Add revoke button to classic subscription settings keyboard and
  multi-tariff detail keyboard (gated by feature toggle)
- Add locale keys for revoke UI in all 5 languages (ru, en, ua, zh, fa)
2026-05-04 08:08:56 +03:00
Fringg 719664208e feat: integrate Antilopay payment provider (API v2)
- Add antilopay_service.py with SHA256WithRSA signing (pycryptodome),
  private key for requests, public key for callback verification
- Add payment mixin with create/callback/finalize/check_status flows,
  kopeks↔rubles conversion, 7 status mappings, prefer_methods support
- Add CRUD with FOR UPDATE locking, idempotency checks
- Add handlers with SBP/Card/SberPay sub-method selection
- Add Alembic migration for antilopay_payments table
- Add config (ANTILOPAY_ENABLED, SECRET_ID, PRIVATE_KEY, PUBLIC_KEY,
  PROJECT_ID, SBP/CARD/SBERPAY enabled/display names)
- Add webhook endpoint with X-Apay-Callback header signature verification
- Register in keyboard, router, utils, backup, method config
2026-05-04 07:44:17 +03:00
Fringg 6524f66da2 feat: integrate Etoplatezhi payment provider
- Add etoplatezhi_service.py with HMAC-SHA512+base64 signature algorithm,
  payment URL builder, and callback signature verification
- Add payment mixin with create/process/finalize flow, 12 status mappings
- Add CRUD operations with FOR UPDATE locking, idempotency checks
- Add Telegram handlers with SBP/Card sub-method selection
- Add Alembic migration for etoplatezhi_payments table
- Add config settings (ETOPLATEZHI_ENABLED, PROJECT_ID, SECRET_KEY,
  SBP_ENABLED, CARD_ENABLED, display names, min/max amounts)
- Add webhook endpoint with JSON-body signature verification
- Register in payment keyboard, router, utils, backup, method config
2026-05-04 07:17:54 +03:00
Fringg 17ac3da3c4 fix: AuraPay webhook signature + add SBP/Card payment method selection
- Fix webhook signature: str(None) produced "None" (4 chars) instead of
  "" like PHP implode() does, causing all webhooks with custom_fields=null
  to fail signature verification
- Add AURAPAY_SBP_ENABLED / AURAPAY_CARD_ENABLED env vars with display
  names, following Freekassa pattern for sub-method selection
- Add aurapay_sbp / aurapay_card buttons in payment keyboard
- Add start_aurapay_sbp_topup / start_aurapay_card_topup handlers
- Route dispatch handles aurapay / aurapay_sbp / aurapay_card
- payment_utils updated with SBP/Card availability checks
- service parameter ("sbp"/"card") now passed through to AuraPay API
2026-05-04 06:44:03 +03:00
Fringg e85c40f8cd fix: apple refund handler — lock apple_transactions row to prevent double deduction 2026-05-04 05:54:11 +03:00
Fringg ecde2fb8f0 feat: Apple IAP integration with security hardening 2026-05-04 05:49:38 +03:00
Fringg 99648a956e fix: persist campaign across bot→webapp registration handoff via Redis 2026-05-04 05:27:49 +03:00
Fringg 2478ff7c3d fix: expired_1d notification — use PricingEngine instead of hardcoded PRICE_30_DAYS 2026-05-04 05:14:47 +03:00
Fringg 2385814d77 fix: guide mode buttons — support external type alias, extract urlScheme from blocks
From PR #2923 by @dotX12, with improvements:
- Support type: "external" as alias for "externalLink" in app config
- Extract urlScheme from subscriptionLink buttons in blocks[] when not at root
- Wrap custom URL schemes in HTTPS redirect for Telegram compatibility
- Fallback to plain subscription URL when no redirect template configured

Improvements over original PR:
- Also check btn.get('url') not just btn.get('link') for scheme extraction
- Validate extracted scheme contains :// before accepting
- Skip redundant redirect wrapping when create_deep_link already wrapped
2026-05-04 05:04:27 +03:00
Egor df7e397745 Merge pull request #2918 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.53.0
2026-04-29 12:12:57 +03:00
github-actions[bot] 52868eac5b chore(main): release 3.53.0 2026-04-29 09:12:34 +00:00
Egor 4c600b8557 Merge pull request #2917 from BEDOLAGA-DEV/dev
Dev
2026-04-29 12:11:47 +03:00
Fringg 51dfc3a1a2 feat: protect active paid subscriptions from bulk delete
- Backend: _do_delete_subscription refuses to delete active paid subs
  unless force_delete_active_paid=true is explicitly passed
- Backend: add force_delete_active_paid to BulkActionParams (default false)
- Backend: add is_trial to SubscriptionListItem schema + populate it
2026-04-29 11:31:08 +03:00
Fringg 443a826402 fix: PayPear webhook signature — strip signature field before hashing + IP fallback
The old code hashed the full raw body INCLUDING the 'signature' field
itself — a circular computation that can never match (you can't include
the signature in the data being signed).

Fix:
1. Strip 'signature' key from payload before HMAC-SHA256 computation
2. Try both sorted and unsorted keys (PayPear docs don't specify)
3. Fallback to IP allowlist check (158.160.85.101 per PayPear docs)
4. Pass client_ip from request headers to the verification function
2026-04-29 11:23:20 +03:00
Fringg 06db393488 feat: add bulk_actions, info_pages, news to PERMISSION_REGISTRY
- bulk_actions: read, execute (was using users:edit)
- info_pages: read, create, edit, delete (was using settings:read/edit)
- news: read, create, edit, delete (was missing from registry entirely)

Backend endpoints updated to use dedicated permissions instead of
piggybacking on users:edit / settings:read.
2026-04-29 11:14:24 +03:00
Fringg 0bcb804118 fix: block/unblock endpoints — correct args, response schema, panel sync
4 bugs fixed:
1. block_user() called with User object instead of int user_id, missing admin_id
2. Response used wrong fields (user_id/status instead of old_status/new_status)
3. Return value not checked — reported success even on failure
4. unblock endpoint used DB-only update_user_status instead of UserService.unblock_user
2026-04-29 10:51:23 +03:00
Fringg 735e16afeb fix: cabinet /block endpoint now disables panel user in RemnaWave 2026-04-29 10:47:40 +03:00
Fringg a88e3c80ad fix: traffic addon price mismatch — keyboard showed prorated, handler charged full month
Keyboard calculated: price * days_remaining / 30 (true proration)
Handler calculated: price * max(30, days_remaining) / 30 (always >= 30 days)

With 17 days remaining: keyboard showed 84₽, handler charged 149₽.

Fix: change calculate_prorated_price default min_charge_days from 30 to 1.
Now all callers (traffic, countries, servers, miniapp, auto-purchase)
use true proration matching the displayed price.
2026-04-29 10:42:10 +03:00
Fringg 1110d0c781 fix: media upload leaks staging photo to admin chat
The upload endpoint sent files to the admin notification chat to obtain
a Telegram file_id, but never deleted the staging message. Admins saw
uncontextualized images in their chat before any ticket was created.

Fix: send with disable_notification=True and immediately delete the
staging message after capturing the file_id. Telegram persists file_ids
even after message deletion.
2026-04-29 10:32:55 +03:00
Fringg 62e7ecba01 fix: deadlock on user deletion — webhook handler never checked intentional mark
mark_intentional_panel_deletion was called before api.delete_user,
but _is_intentional_panel_deletion_event was never called in the
webhook handler — it was dead code. The user.deleted webhook processed
unconditionally, causing a deadlock between delete_user_account (Tx1
holding subscription row locks) and the webhook handler (Tx2 trying
to lock the same rows via decrement_subscription_server_counts).

Fix: check _is_intentional_panel_deletion_event at the top of
_handle_user_deleted — if True, log and return immediately without
touching the DB.
2026-04-29 10:28:57 +03:00
Fringg c905fa6000 fix: downgrade Pal24 API validation errors from error to debug 2026-04-29 10:25:08 +03:00
Fringg 768e0b6a73 fix: PollResponse has no created_at — use sent_at for ordering 2026-04-29 10:17:40 +03:00
Fringg 83efc214fe fix: add 6 missing payment providers to payment_utils availability checks
RollyPay (and 5 others) showed buttons but triggered "payment methods
unavailable" because get_available_payment_methods() was missing them.
The keyboard builder (inline.py) had all providers, but the text
generator (payment_utils.py) did not — divergent hand-maintained lists.

Added to all 4 functions: get_available_payment_methods,
is_payment_method_available, get_payment_method_status,
get_enabled_payment_methods_count:
- SeverPay, PayPear, RollyPay, Overpay, AuraPay (new)
- RioPay (was in methods list but missing from status/count)
2026-04-29 08:27:37 +03:00
Fringg 29e177d396 fix: cabinet autopay endpoint — same NULL-safe is_trial guard 2026-04-29 08:21:41 +03:00
Fringg 2fbdbf5ab0 fix: autopay renewing trial subscriptions at classic-mode pricing
Three bugs caused trial subscriptions to be auto-renewed without a
tariff at arbitrary prices:

1. try_auto_extend_expired_after_topup: is_trial guard used truthiness
   check — NULL (legacy rows) passed as falsy. Changed to
   `is_trial is not False` (NULL-safe).

2. Multi-tariff branch: `not s.is_trial` treated NULL as not-trial.
   Changed to `s.is_trial is False`.

3. Telegram bot autopay toggle: no is_trial guard — users could enable
   autopay on trial subscriptions. Added trial check before enabling.
2026-04-29 08:16:39 +03:00
Fringg 422844d78d fix: retry queue action uses _should_create instead of stale subscription UUID 2026-04-29 08:08:24 +03:00
Fringg f37eb9a1bd fix: cabinet purchase fails after panel user deletion — stale UUID
Two bugs caused "RemnaWave UUID не найден" when a user repurchased
after their panel user was deleted (expired user cleanup):

1. Webhook handler only cleared subscription.remnawave_uuid in
   multi-tariff mode. In single-tariff mode the stale UUID remained,
   causing the cabinet to try update_remnawave_user on a deleted
   panel user instead of creating a new one.

2. Cabinet purchase-tariff used subscription.remnawave_uuid for the
   create/update decision. In single-tariff mode this was stale.
   Now mirrors the bot handler logic: checks user.remnawave_uuid
   in single-tariff mode (correctly cleared by webhook).
2026-04-29 08:04:23 +03:00
Fringg 1c38b31e60 fix: send admin notification on promo code activation from cabinet 2026-04-29 07:50:09 +03:00
Fringg 43dd0fd92c fix: referral links now clickable — remove <code> wrapping
The invite message wrapped the entire text including the referral URL
in <blockquote><code>...</code></blockquote>. The <code> tag made the
URL non-clickable — Telegram renders it as monospace copyable text.
Recipients couldn't tap the link to open it.

- Invite message: removed <code> from blockquote, Telegram now auto-links the URL
- Stats panel: removed <code> from bot/cabinet referral links, URLs are now clickable
2026-04-29 07:45:28 +03:00
Fringg a506c6be00 fix: add 5 missing payment providers to pending-payments model_map 2026-04-29 07:40:50 +03:00
Fringg ff7b190527 fix: add RollyPay, PayPear, Overpay, AuraPay to REAL_PAYMENT_METHODS 2026-04-29 07:36:48 +03:00
Fringg 527c5b4498 fix: panel sync subscription duration — ceil for days_remaining 2026-04-29 07:32:08 +03:00
Fringg bada41ecd6 fix: remaining pricing-critical .days floor calculations → math.ceil
Same bug as device pricing: timedelta.days floors partial days.
Fixed 14 more pricing-critical locations across 7 files:

- traffic addon pricing (bot handler + cabinet + miniapp)
- country addon pricing (bot handler + miniapp)
- generic addon pricing helper (common.py)
- auto-purchase device recomputation
- subscription CRUD pricing helper

Display-only .days usages intentionally left as floor (correct for
showing "X days left" to users).
2026-04-29 07:27:55 +03:00
Fringg cf60ae2967 fix: device/traffic addon pricing — use ceil instead of floor for days_left
timedelta.days is integer floor: 29 days 23 hours = 29, not 30.
When a user bought extra devices on the same day as their subscription,
they were charged for ~1 day instead of the full remaining period.

Fix: math.ceil(total_seconds / 86400) rounds partial days UP.
Applied to all 11 locations across 4 files:
- app/handlers/subscription/devices.py (5 spots)
- app/cabinet/routes/subscription_modules/devices.py (3 spots)
- app/keyboards/inline.py (3 spots — display pricing)
- app/utils/pricing_utils.py (1 spot — traffic prorated pricing)
2026-04-29 07:21:07 +03:00
Fringg 47c7d45793 fix: traffic addon discount also bypassed tariff-promo-group check 2026-04-29 07:14:14 +03:00
Fringg 4ab5928b61 fix: promo group discount applied to restricted tariffs in autopay
The pricing engine applied promo group discounts unconditionally,
without checking if the tariff is available for the user's promo group.

In autopay: user with VIP group (60% discount, restricted to Premium
tariff) would get 60% off when auto-renewing a Basic tariff that their
group should not cover.

Fix: in _calculate_tariff_core, check tariff.is_available_for_promo_group
before applying group discounts. If tariff is not available for the
user's promo group, the discount is zeroed — subscription renews at
full price. Protects ALL pricing paths (autopay, recurrent, manual).
2026-04-29 07:09:55 +03:00
Fringg fb857d792b feat: per-category enable/disable for admin notifications
Add ADMIN_NOTIFICATIONS_{CATEGORY}_ENABLED settings (default True) for
all 10 notification categories: purchases, renewals, trials, balance,
addons, infrastructure, errors, promo, partners, tickets.

Setting ADMIN_NOTIFICATIONS_PROMO_ENABLED=false now completely suppresses
promo notifications (promocode activations, campaign visits, promo group
changes) instead of silently falling back to the general topic.

Also fix referral_contest_service direct bot.send_message bypass —
now respects ADMIN_NOTIFICATIONS_PROMO_ENABLED setting.
2026-04-29 06:58:57 +03:00
Fringg 59080f7392 fix: handle A018 error code in admin_users sync endpoints (2 more locations) 2026-04-29 06:52:04 +03:00
Fringg c619dbcae2 fix: handle A018 error code as user-not-found fallback to create_user 2026-04-29 06:48:30 +03:00
Fringg 91de6d03fc fix: update cabinet_last_login on every request (throttled, 5 min) 2026-04-29 06:46:02 +03:00
Fringg 1fc04d842f fix: subscription-request-history — correct API client usage, add ownership check 2026-04-29 06:18:32 +03:00
Fringg e22beb7229 feat: subscription request history API + RemnaWave panel method
- Add get_subscription_request_history to RemnaWave API client
  (GET /api/users/{uuid}/subscription-request-history with pagination)
- Add GET /admin/users/{user_id}/subscription-request-history endpoint
  with subscription_id param for multi-tariff support
2026-04-29 06:12:38 +03:00
Fringg 74999fe99d fix: create locales directory with correct permissions in Dockerfile 2026-04-29 05:55:21 +03:00
Fringg 134e7fb0e1 fix: false subscription expiry notifications — 4 bugs fixed
1. _check_expired_subscription_followups: added Subscription.status=EXPIRED
   filter (was matching ALL statuses including ACTIVE), User.status=ACTIVE
   filter, and 30-day lookback window to stop scanning ancient subscriptions

2. _get_expiring_paid_subscriptions: added User.status=ACTIVE filter to
   prevent sending "expiring" notifications to blocked/deleted users

3. Multi-tariff: before sending expired/followup notifications, check if
   user has another ACTIVE subscription with end_date > now — skip if they
   still have service through another tariff

4. Multi-tariff: same check for _check_expired_subscriptions — don't send
   "subscription expired" if user has another active sub
2026-04-29 05:47:56 +03:00
Fringg c743fc81a5 fix: replace all late callback.answer() with edit_text for error feedback
- Fix 7 intermediate error paths (balance deduction failures) that used
  callback.answer() after the early answer was already consumed — user
  got no error feedback at all
- Fix 2 unfixed handlers: confirm_tariff_purchase, confirm_daily_tariff_purchase
  — same early-answer pattern applied
- All 7 purchase/extend/switch handlers now consistently use early
  callback.answer() + edit_text for errors
2026-04-29 05:37:30 +03:00
Fringg 579e4f2a69 fix: callback.answer() before heavy operations to prevent query timeout
Telegram invalidates callback queries after 30 seconds. When the bot
performed panel sync, DB transactions, and admin notifications before
answering, callback.answer() threw TelegramBadRequest: query is too old.

Moved callback.answer() to immediately after guard checks (balance,
tariff availability) in 5 handlers:
- confirm_tariff_extend
- confirm_custom_tariff_purchase
- confirm_tariff_switch
- confirm_daily_tariff_switch
- confirm_instant_switch

Error feedback now uses callback.message.edit_text() instead of the
expired callback.answer().
2026-04-27 16:56:39 +03:00
Fringg b9b695799c refactor: remove unused EXTERNAL_ADMIN_TOKEN functionality
- Delete app/services/external_admin_service.py entirely
- Remove EXTERNAL_ADMIN_TOKEN and EXTERNAL_ADMIN_TOKEN_BOT_ID from config
- Remove build_external_admin_token, get_external_admin_token, get_external_admin_bot_id methods
- Remove unused hashlib/hmac imports from config.py
- Remove from system_settings_service: READ_ONLY_KEYS, PLAIN_TEXT_KEYS,
  category title, category description, prefix mapping, documentation metadata
- Remove from bot_configuration.py category group
- Remove from main.py startup sequence (ensure_external_admin_token call)
- Remove from .env.example
- Remove from docs/project_structure_reference.md
2026-04-26 19:54:33 +03:00
Fringg 5cf19c76e6 fix: backup import crash + upload handler hardening
- Fix PaypearPayment → PayPearPayment (capital P) — import crash
- Fix AurapayPayment → AuraPayPayment (capital P) — import crash
- Update upload instruction message to mention .tar.gz format
- Add null guard on document.file_name before extension check
2026-04-26 19:39:09 +03:00
Fringg eafb243882 fix: backup completeness — add 15 missing tables, accept .tar.gz uploads
Tables added to backup AND clear lists:
- Payment providers: riopay, severpay, paypear, rollypay, overpay, aurapay, saved_payment_methods
- Content: email_templates, info_pages, news_articles, news_categories, news_tags
- Landing: landing_pages, guest_purchases
- Analytics: yandex_client_id_map

Also:
- Telegram backup upload handler now accepts .tar.gz format (was .json/.json.gz only)
- All 92 ORM models + 3 association tables now covered
2026-04-26 19:29:55 +03:00
Egor 56b0b1fb5f Merge pull request #2914 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.52.1
2026-04-24 18:22:53 +03:00
github-actions[bot] 10519bf68e chore(main): release 3.52.1 2026-04-24 15:22:37 +00:00
Egor 7bff56070c Merge pull request #2913 from BEDOLAGA-DEV/dev
Dev
2026-04-24 18:22:09 +03:00
Fringg 5ed9a0d4fb fix: use fresh DB session for deactivate after long unpin loop 2026-04-24 18:13:48 +03:00
Fringg 63e1127353 fix: broadcast preview count — add .correlate(User) to EXISTS subqueries 2026-04-24 18:03:29 +03:00
Fringg ab4661b5c6 fix: unpin messages in Telegram BEFORE deactivating in DB
The "Unpin all" button called deactivate_active_pinned_message() first,
then looped over users to unpin. If Telegram API calls failed or timed
out, the message was already marked inactive in the DB with no way to
retry. Now: get active message → unpin from all chats → deactivate in DB.
2026-04-24 18:01:52 +03:00
Fringg 52bf2a9589 fix: ignore bot's own messages in unknown message handlers 2026-04-24 17:55:37 +03:00
Egor 14e24a546e Merge pull request #2911 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.52.0
2026-04-24 17:12:13 +03:00
github-actions[bot] f60231e104 chore(main): release 3.52.0 2026-04-24 14:11:54 +00:00
Egor 920bab5b4f Merge pull request #2910 from BEDOLAGA-DEV/dev
Dev
2026-04-24 17:11:17 +03:00
Fringg 68d2350dfd fix: stop printing tracebacks for warning-level logs inside except blocks 2026-04-24 17:09:20 +03:00
Fringg 7d512d214a fix: integrate Yandex Metrika offline conv + S2S postback hooks
Restore integration hooks dropped in PR #2851 merge:
- PurchaseRequest accepts yandex_cid, referrer, subid from frontend
- Cache yandex_cid and subid in Redis at purchase creation (24h TTL)
- On fulfill_purchase: extract subid from cache, persist to DB
- Save Yandex CID from Redis to yandex_client_id_map
- Fire on_registration + S2S postback for new accounts
- Fire on_purchase + S2S postback for all paid purchases
- All hooks wrapped in try/except — failures never block delivery
2026-04-24 16:59:27 +03:00
Fringg 2cde38c63b fix: restore referrer field in admin landing purchases response 2026-04-24 16:53:47 +03:00
Fringg 24dc8d2a5e fix: restore HTTP Referer fallback for landing purchases 2026-04-24 16:47:22 +03:00
Fringg 1522d35f2d fix: gift purchases no longer inflate promo group level
Two bugs caused max promo group assignment on gift send/activate:

1. Buyer: GIFT_PAYMENT was counted in get_user_total_spent_kopeks
   alongside SUBSCRIPTION_PAYMENT. Now only SUBSCRIPTION_PAYMENT
   counts as personal spending for promo group auto-assignment.

2. Recipient: fulfill_purchase and activate_purchase created a
   SUBSCRIPTION_PAYMENT transaction for the recipient with the full
   gift price. Now skipped for gift recipients — they didn't pay.
2026-04-24 16:43:32 +03:00
Fringg 2b0d8a2a88 style: ruff format admin_bulk_actions.py 2026-04-24 16:34:43 +03:00
Fringg 9217f4116f fix: sanitize error messages in all bulk action catch-all handlers
Replace str(exc) with generic 'Action failed: internal error' in both
_execute_for_user and _execute_for_subscription catch-all blocks.
Prevents leaking internal paths, SQL details, or connection strings.
2026-04-24 16:24:45 +03:00
Fringg 2e45a93bd7 fix: bulk delete_user — pass real admin_id, sanitize error messages
- Thread admin_id through _execute_for_user to _do_delete_user for audit trail
- Replace raw exception str(e) with generic error message in client response
- Add subscriptions=[] to failure paths to prevent MissingGreenlet
2026-04-24 16:16:50 +03:00
Fringg d77fd81e16 feat: bulk actions — campaign/partner filters, delete_user action
- Add campaign_id and partner_id query params to GET /admin/users
- Filter users by advertising campaign via EXISTS subquery on registrations
- Filter users by partner via JOIN campaign registrations → campaigns
- Add DELETE_USER bulk action type with delete_from_panel param
- Handler calls UserService.delete_user_account for full bot+panel removal
- Permission check: users:delete required for delete_user action
- Add to _USER_LEVEL_ACTIONS (operates on user_ids, not subscription_ids)
2026-04-24 16:07:14 +03:00
Fringg bdb8cab1c9 feat: info page tab replacement — replaces_tab field + API
- Add replaces_tab column to InfoPage ('faq','rules','privacy','offer')
- Migration 0067: add nullable replaces_tab column
- CRUD: clear_replaces_tab ensures one page per tab, get_tab_replacements
  returns {tab: slug} mapping for active pages
- Admin routes: auto-clear old assignment on create/update
- Public route: GET /info-pages/tab-replacements (no auth)
- Schemas: replaces_tab with regex validation in all request/response models
2026-04-24 14:21:32 +03:00
Fringg d394565fe9 feat: FAQ support in info pages — page_type field + migration
- Add page_type column to InfoPage model ('page' or 'faq')
- Migration 0066: ALTER TABLE ADD COLUMN with server_default='page'
- Update schemas with page_type field and regex validation
- Update CRUD: page_type in create, filter in list
- Update admin/public routes with page_type query filter
- Backward compatible: existing pages default to type 'page'
2026-04-24 14:01:18 +03:00
Fringg 122d12db20 fix: /reorder route unreachable — move before /{page_id} path param 2026-04-24 13:50:38 +03:00
Fringg 2071a680d3 fix: info pages review — deduplicate slug index, type reorder items
- Remove triple-redundant slug index: keep only unique=True on column
  (PostgreSQL creates unique index automatically), remove __table_args__
  index and explicit create_index in migration
- Type ReorderRequest.items with ReorderItem(id: int, sort_order: int)
  instead of raw dict — prevents unvalidated input causing 500
- Migration downgrade: just drop_table (unique constraint drops with it)
2026-04-24 08:21:41 +03:00
Fringg e4b4a54797 feat: information pages — CRUD model, admin API, public API
- InfoPage model: slug, title (JSONB locale dict), content (JSONB),
  is_active, sort_order, icon, created_at/updated_at
- CRUD: create, get by id/slug, list, update, delete, reorder
- Admin routes: /admin/info-pages with full CRUD, toggle-active, reorder
  (permissions: settings:read/settings:edit)
- Public routes: /info-pages list active, /info-pages/{slug} get by slug
- Migration 0065: create info_pages table with unique slug index
- Custom pages support: admins can create any info page with any slug
2026-04-24 08:09:54 +03:00
Fringg 59c54c9b39 fix: privacy policy and offer text display HTML links as plain text
parse_mode='HTML' was missing from message sends during /start
registration. HTML tags like <a href="..."> were shown as literal
text instead of rendered links.

Fixed in 4 places:
- Privacy policy edit_text (line 1151)
- Privacy policy fallback answer (line 1158)
- Welcome/offer text in complete_registration_from_callback (line 1729)
- Welcome/offer text in complete_registration (line 2084)
2026-04-24 07:43:54 +03:00
Fringg 0d2b1dfdc9 feat: support multiple tariff_ids in user list filter
tariff_id query param now accepts comma-separated IDs (e.g.
tariff_id=1,3,5). CRUD functions updated to use IN() operator
for multi-tariff server-side filtering. Pagination works correctly
with multiple tariffs selected.
2026-04-24 07:16:33 +03:00
Fringg 605f202191 feat: bulk delete_subscription action — removes from bot DB + RemnaWave
Deactivates user in RemnaWave panel first, then deletes subscription
with related SubscriptionServer and TrafficPurchase records.
Subscription-level action (works with subscription_ids targeting).
2026-04-24 06:50:44 +03:00
Fringg be787a85bf feat: bulk set_devices action + device info in subscription list
- Add SET_DEVICES bulk action: sets device_limit on subscriptions,
  syncs to RemnaWave panel (subscription-level action)
- Add device_limit to SubscriptionListItem and BulkSubscriptionInfo
  schemas for frontend display
- Populate device_limit in _build_user_list_item and
  _build_subscription_info helpers
2026-04-24 06:35:29 +03:00
Fringg ff41ea9abb fix: suppress 'User already enabled' traceback in bulk add_traffic 2026-04-24 06:11:02 +03:00
Fringg cfbcc3082f fix: always return subscriptions list in user list API
subscriptions were only populated when is_multi_tariff_enabled()
was true. Users with multiple subscriptions in regular tariff mode
had empty subscriptions[] — bulk actions couldn't show or select them.
Now subscriptions are always populated regardless of tariff mode.
2026-04-24 06:08:23 +03:00
Fringg 2ad893badf fix: MissingGreenlet in subscription-ids bulk actions
_execute_for_subscription accessed user.subscriptions after commit,
triggering async lazy load → MissingGreenlet. Actions committed
successfully but reported as failed with cryptic error message.

Fix: use _build_subscription_info([sub]) with the already-loaded
targeted subscription instead of trying to lazy-load the full
user.subscriptions list.
2026-04-24 05:55:06 +03:00
Fringg e78177b2fc feat: multi-tariff bulk actions — subscription-level targeting
- Add subscription_ids to BulkExecuteRequest (mutually exclusive with
  user_ids via model_validator). Admins can now target specific
  subscriptions instead of auto-resolving the first active one.
- Add _execute_for_subscription dispatcher that loads subscription by
  ID, gets user, and passes sub_override to action handlers
- Add sub_override parameter to all 5 subscription-level handlers
  (extend, cancel, activate, change_tariff, add_traffic) — bypasses
  _resolve_subscription when a specific subscription is targeted
- Add SubscriptionListItem to UserListItem response — in multi-tariff
  mode, each user row includes all their subscriptions with tariff
  name, status, days remaining, traffic info
- User-level actions (add_balance, assign_promo_group, grant) reject
  subscription_ids with 400
- Add subscription_id field to BulkUserResult and SSE progress events
- Add _stream_bulk_execute_subscriptions SSE generator
- Backward compatible: existing user_ids requests work unchanged
2026-04-24 05:41:04 +03:00
Fringg daa472570c fix: add subscription/tariff/promo_group filters to admin user list API
The bulk actions page filters (subscription_status, tariff_id,
promo_group_id) were sent by the frontend but ignored by the
backend — the list_users endpoint had no such parameters.

- Add subscription_status, tariff_id, promo_group_id query params
  to GET /cabinet/admin/users
- Add subscription-level filtering via subquery in get_users_list
  and get_users_count CRUD functions
- Add tariff_id, tariff_name, traffic_used_gb, traffic_limit_gb,
  device_limit, days_remaining to UserListItem response schema
- Populate tariff info from subscription.tariff relationship in
  _build_user_list_item
2026-04-24 05:24:53 +03:00
Fringg db7b6734fd fix: bulk change_tariff not clearing squads when new tariff has none
When switching to a tariff with empty/null allowed_squads, the old
tariff's squads were preserved on the subscription. Now always sets
connected_squads from the new tariff (or empty list).
2026-04-24 05:10:47 +03:00
Fringg c0e0756b9a feat: bulk actions — SSE streaming progress, grant subscription, multi-tariff info
- Add SSE streaming mode (?stream=true): per-user progress events with
  real-time success/error counts, final summary event
- Add GRANT_SUBSCRIPTION action: creates new subscription with tariff,
  skips users who already have that tariff (multi-tariff aware),
  handles IntegrityError with graceful rollback
- Add BulkSubscriptionInfo: returns all user's subscriptions in each
  result for multi-tariff visibility
- Refactor: extract _validate_and_prepare and _execute_for_user helpers
  shared by streaming and non-streaming paths
2026-04-24 04:32:16 +03:00
Fringg 5b45d4354f fix: bulk actions review — rollback on error, multi-tariff constraint checks
- Add db.rollback() in per-user exception handler to prevent session
  poisoning (one failed commit would abort all subsequent users)
- Add multi-tariff duplicate subscription check in activate_subscription
  (prevents uq_subscriptions_user_tariff_active violation)
- Add multi-tariff duplicate subscription check in change_tariff
  (prevents switching to a tariff the user already holds)
2026-04-24 04:24:20 +03:00
Fringg fb2773fee4 feat: admin bulk actions API — mass operations on users
Add POST /cabinet/admin/bulk/execute endpoint for applying operations
to multiple users at once (up to 500 per request):

- extend_subscription / add_days: extend subscription by N days
- cancel_subscription: deactivate and expire subscriptions
- activate_subscription: reactivate expired subscriptions
- change_tariff: switch tariff without changing remaining days
- add_traffic: add extra GB to subscription traffic
- add_balance: credit balance with transaction record
- assign_promo_group: set or remove promo group for users

Features: dry_run preview mode, partial success handling (per-user
try/except), param validation before loop, auto-panel sync after
subscription mutations, multi-tariff mode support
2026-04-24 04:15:07 +03:00
Fringg ae7feeb726 fix: server squad sync fails on fresh DB without default promo group
On first startup with empty database, ensure_servers_synced() fetched
squads from RemnaWave but create_server_squad() failed with
ValueError('Server squad must be linked to at least one promo group')
because no default promo group existed yet.

Now _get_default_promo_group_id() auto-creates a default promo group
via _get_or_create_default_promo_group() when none exists, matching
the pattern used throughout the codebase for user registration.
2026-04-24 03:32:29 +03:00
Egor bcf5519880 Merge pull request #2904 from BEDOLAGA-DEV/dev
docs: add Overpay to README with partner block
2026-04-23 05:29:22 +03:00
Fringg 70568f82c5 docs: add Overpay to README with partner block 2026-04-23 05:25:49 +03:00
Egor 6fbd0bff28 Merge pull request #2903 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.51.0
2026-04-23 05:20:56 +03:00
github-actions[bot] dff3eb14a1 chore(main): release 3.51.0 2026-04-23 02:20:25 +00:00
Egor fab3d54f24 Merge pull request #2902 from BEDOLAGA-DEV/dev
Dev
2026-04-23 05:20:02 +03:00
Fringg 2c3ffc8c8a feat: integrate Overpay payment provider (pay.overpay.io)
Full integration across bot, cabinet, admin panel and landing pages:

- Config: 16 OVERPAY_* settings (API URL, credentials, P12 cert path,
  project ID, currency, amount limits, webhook path, payment methods)
- Service: overpay_service.py with httpx mTLS (P12 cert) + Basic Auth,
  create_payment, get_payment, refund_payment methods
- Payment mixin: OverpayPaymentMixin with create, webhook processing,
  finalize (balance credit + notifications), status check
- Model: OverpayPayment table + OVERPAY enum value
- CRUD: 9 standard operations (create, get_by_*, for_update, link)
- Bot handler: start_overpay_topup + process_overpay_payment_amount
- Handler registration: route_payment_by_method + callback registration
- Webhook: POST /overpay-webhook with DB-based anti-spoofing validation
- Cabinet: balance topup + guest payment dispatch
- Keyboard: payment method button in bot
- Admin: settings category + test payment button
- Infrastructure: payment_method_config_service, system_settings_service,
  payment_verification_service, payment_search_service
- Migration 0064: create overpay_payments table

Overpay API specifics: amount as string "100.00" (not kopeks),
success status "charged", HPP redirect via resultUrl
2026-04-23 04:36:56 +03:00
Fringg 6f87563789 fix: pad short RemnaWave usernames to meet 3-char minimum
RemnaWave API requires username >= 3 characters. Users with short
Telegram names (e.g. "Su") produced 2-char usernames that failed
validation, preventing subscription sync after payment.

Now format_remnawave_username pads short results with the user
identifier (telegram_id/email/user_id) to ensure minimum length.
2026-04-23 03:49:35 +03:00
Fringg 7005052156 fix: inactive user cleanup deletes users with paid subscriptions
The get_inactive_users query filtered only by last_activity (last bot
interaction), ignoring subscription end dates. Users who bought long
subscriptions (3-6-12 months) but didn't interact with the bot got
flagged as inactive and deleted+banned while their subscription was
still active or recently expired.

Fix: add SQL subquery excluding users who have ANY subscription with
end_date >= threshold_date. A user is now only deletable when BOTH
their last_activity AND their latest subscription end_date are older
than the configured inactivity period.
2026-04-23 03:40:45 +03:00
Fringg 29ae7089aa feat: respond to unknown media messages (photos, videos, documents)
Previously the bot only responded "Не понимаю эту команду" to text
messages. Photos, videos, documents and stickers sent outside of an
active FSM state were silently ignored, causing users to think their
media was received when it wasn't (e.g. support ticket screenshots
sent as separate messages).

Now the bot replies with the same "use menu buttons" message for any
unhandled media when no FSM state is active.
2026-04-23 03:31:21 +03:00
Egor 80e953a07b Merge pull request #2901 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.50.0
2026-04-22 06:18:07 +03:00
github-actions[bot] aebf58068f chore(main): release 3.50.0 2026-04-22 03:17:36 +00:00
Egor a491fe34bd Merge pull request #2900 from BEDOLAGA-DEV/dev
feat: v3.50.0 release
2026-04-22 06:17:09 +03:00
Egor 830e64afe0 Dev (#2899)
* fix: устранить MissingGreenlet в автоплатежах и починить traceback в логах

- subtract_user_balance: пишем promo_offer_log в отдельной сессии вместо rollback после commit, который экспайрил объекты основной сессии и ломал последующие обращения к subscription/user attrs
- monitoring_service._process_autopayments: перезагружаем subscription с eager-load user/tariff после списания, оборачиваем каждую итерацию в try/except + rollback, чтобы одна ошибка не валила весь батч
- logging_config: новый processor _auto_capture_exc_info автоматически подтягивает traceback из sys.exc_info() или error-kwarg → полный traceback в файле, консоли и Telegram без exc_info=True на каждом вызове
- logging_handler: дублирующая логика захвата exc_info в TelegramNotifierProcessor как резерв

* fix: устранить root cause MissingGreenlet в автоплатежах через refetch по id

Трейс показал: subscription.user падает на lazy-load → pool._checkout →
do_ping → await_ → MissingGreenlet. SQLAlchemy 2.0 async session не
поддерживает sync-lazy-load для relationships. Причина рассинхрона:
lock_user_for_pricing делает populate_existing=True + selectinload(
User.subscriptions).selectinload(Subscription.tariff), что разгружает
Subscription.user backref для сестринских подписок того же user.
Последующее обращение sub.user у другой подписки падает.

Фикс: захватываем (sub_id, user_id) пары ДО цикла, каждую итерацию
делаем fresh refetch через async select с eager load user+tariff+
promo_group. Никаких lazy access в горячем пути. В except используем
локально захваченные id вместо getattr(subscription, ...), чтобы
логирование не падало каскадом на expired объекте.

* fix: grant all available squads for unrestricted trials (#2897)

* feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook (#2894)

* feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook

* style: ruff format main.py

---------

Co-authored-by: Dmitry Lunin <br@slack.ru>

* fix: do not update first_name/last_name from OIDC claims (#2892)

Co-authored-by: Dmitry Lunin <br@slack.ru>

* fix: do not reset subscription_crypto_link when cryptoLink absent in webhook (#2891)

Co-authored-by: Dmitry Lunin <br@slack.ru>

* fix: FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal

- balance/platega: re-set FSM state after min/max validation errors,
  set state before pending_amount path, use balance_topup callback for back button
- balance/main: set FSM state and payment_method in handle_topup_amount_callback
  for all providers before routing, use balance_topup callback in validation errors
- payment/paypear: fix confirmation_url key (was 'url'), add fallback,
  store charged amount with commission for correct webhook amount comparison
- tariff_purchase: redirect to active tariff list when current tariff is
  inactive (hidden trial after promo code activation)
- cabinet/renewal: check tariff.is_active in both GET and POST endpoints
  to prevent hidden trial tariff periods from appearing

* fix: tariff switch pricing showing free for upgrades, admin duplicate subscription guard

- pricing_engine: use shortest period for daily rate comparison instead
  of period closest to remaining_days — fixes incorrect free/zero cost
  for upgrades when tariffs have different period sets
- pricing_engine: remove unused target_days parameter from
  get_tariff_daily_rate_fraction
- admin_users: add duplicate subscription check before create,
  change_tariff and activate actions to prevent UniqueViolationError
  on uq_subscriptions_user_tariff_active constraint
- admin_users: add IntegrityError fallback on create as TOCTOU safety net

* feat: tariff switch direction control, fix device pricing within tariff limit

Tariff switch direction:
- Add TARIFF_SWITCH_UPGRADE_ENABLED and TARIFF_SWITCH_DOWNGRADE_ENABLED
  settings to control allowed switch directions
- Guard all 10 entry points: instant switch (list, preview, confirm),
  legacy switch (list, select, confirm, daily confirm), cabinet (preview,
  execute), purchase-options API
- Filter tariff lists by allowed direction, show "unavailable" when
  both directions disabled
- Expose settings in cabinet purchase-options response for frontend

Device pricing fix:
- Devices within tariff.device_limit are now free when restoring
  (was charging for all devices regardless of tariff inclusion)
- Fix max(100, price) minimum enforcing 1 RUB even when
  chargeable_devices is 0
- Apply fix across all endpoints: bot handlers (confirm_change,
  execute_change, confirm_add), cabinet API (legacy purchase,
  modern purchase, get-price, save-cart), inline keyboard display

* fix: classic mode renewal resets device_limit to 1 via cart key mismatch

- Fix cart key mismatch: extend cart saved 'device_limit' but
  confirm_purchase read 'devices' key, falling back to DEFAULT=1.
  Now both keys are saved in both cart-save paths
- Fix confirm_purchase device resolution: use explicit is None checks
  instead of or-chain to avoid falsy-zero trap
- Fix return_to_saved_cart display: fall back to 'device_limit' and
  'traffic_limit_gb' keys when 'devices'/'traffic_gb' are absent
- Fix second cart-save path in _extend_existing_subscription with
  same dual-key pattern
- Fix RemnaWaveService import path in renewal service
- Add RESET_DEVICES_ON_RENEWAL setting: resets all connected devices
  (hwid) via RemnaWave API on each subscription renewal

* fix: menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports

- Increase icon max_length from 10 to 100 in all three schemas
  (MenuButtonConfig, ButtonUpdateRequest, AddCustomButtonRequest)
  to support Telegram Custom Emoji IDs
- Add traffic_topup_enabled condition to ButtonConditions schema
- Remove shadowing local imports of MenuLayoutService in
  routes/menu_layout.py (top-level import already provides access)

* feat(tickets): multi-media message gallery (media_items JSONB)

- Add media_items JSONB column to TicketMessage model for multi-media
  gallery support (photos/videos/documents in one bubble)
- Add TicketMediaItem schema with type validation and shared
  _validate_media_bundle helper (max 10 items, legacy field compat)
- Update admin and user ticket handlers to store media_items and
  back-fill legacy media_type/media_file_id/media_caption from first
  item for backward compatibility
- Update _message_to_response in both admin and user routes to include
  media_items in API responses
- Allow empty message text when media is attached (message field now
  defaults to empty string with model validator ensuring text or media)
- Add migration 0061 with idempotent column check

Based on PR #2869 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)

* fix: ticket media_items review fixes

- Add if has_media else None guards in user-side ticket handlers
  (create_ticket, add_message) matching admin handler pattern
- Fix Telegram notification using resolved primary_file_id/primary_type
  instead of raw request fields for gallery messages
- Narrow except Exception to (TypeError, KeyError, ValueError) in
  _message_to_response with warning log for debugging
- Add media_items parameter to TicketCRUD.create_ticket and
  TicketCRUD.add_message for CRUD layer parity
- Add TicketMediaItemResponse and media_items field to webapi
  TicketMessageResponse to prevent data loss on read

* feat: landing page analytics goals and sticky pay button

- Add sticky_pay_button, analytics_view_enabled, analytics_view_goal,
  analytics_click_enabled, analytics_click_goal columns to LandingPage
- Add fields to CRUD updatable fields, admin create/update/detail
  schemas, create_landing() kwargs, _landing_to_detail() response
- Expose sticky_pay_button and analytics fields in public landing
  config response for frontend Yandex Metrika integration
- Add migration 0062 with idempotent column checks

Based on PR #2852 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)

* fix: validate analytics goal is set when analytics is enabled on landing

Prevent enabling analytics_view/click without providing the
corresponding goal identifier, which would result in empty
Yandex Metrika calls on the frontend.

* feat: Yandex Metrika offline conversions + S2S postbacks

- Add YandexClientIdMap model for user → yandex_cid mapping with
  upsert-safe CRUD (ON CONFLICT DO UPDATE)
- Add yandex_cid, subid, referrer columns to GuestPurchase
- Add yandex_offline_conv_service: Measurement Protocol integration
  with mc.yandex.ru/collect (registration, trial, purchase events),
  background task management, CID parsing from /start params
- Add s2s_postback_service: server-to-server affiliate postbacks
  with URL template placeholders and URL-safe encoding
- Add analytics offline conversion info to branding API (masked secret)
- Add POST /analytics/yandex-cid endpoint for cabinet CID capture
- Add 11 config settings (YANDEX_OFFLINE_CONV_*, S2S_POSTBACK_*)
- Add migration 0063 (yandex_client_id_map table + guest_purchases cols)
- Fix: mask measurement secret aggressively (show only last 4 chars)
- Fix: always replace {user_id} placeholder in S2S postback URLs
- Fix: use structlog kwargs instead of f-strings with LOG_PREFIX

Based on PR #2851 by @smediainfo — CI/CD workflow changes excluded

---------

Co-authored-by: c0mrade <killmy666@gmail.com>
Co-authored-by: Danila Yudin <danyayudin2012@gmail.com>
Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com>
Co-authored-by: Dmitry Lunin <br@slack.ru>
2026-04-22 06:08:26 +03:00
Fringg 1068c1387a feat: Yandex Metrika offline conversions + S2S postbacks
- Add YandexClientIdMap model for user → yandex_cid mapping with
  upsert-safe CRUD (ON CONFLICT DO UPDATE)
- Add yandex_cid, subid, referrer columns to GuestPurchase
- Add yandex_offline_conv_service: Measurement Protocol integration
  with mc.yandex.ru/collect (registration, trial, purchase events),
  background task management, CID parsing from /start params
- Add s2s_postback_service: server-to-server affiliate postbacks
  with URL template placeholders and URL-safe encoding
- Add analytics offline conversion info to branding API (masked secret)
- Add POST /analytics/yandex-cid endpoint for cabinet CID capture
- Add 11 config settings (YANDEX_OFFLINE_CONV_*, S2S_POSTBACK_*)
- Add migration 0063 (yandex_client_id_map table + guest_purchases cols)
- Fix: mask measurement secret aggressively (show only last 4 chars)
- Fix: always replace {user_id} placeholder in S2S postback URLs
- Fix: use structlog kwargs instead of f-strings with LOG_PREFIX

Based on PR #2851 by @smediainfo — CI/CD workflow changes excluded
2026-04-22 05:57:46 +03:00
Fringg d31632534b fix: validate analytics goal is set when analytics is enabled on landing
Prevent enabling analytics_view/click without providing the
corresponding goal identifier, which would result in empty
Yandex Metrika calls on the frontend.
2026-04-22 05:43:54 +03:00
Fringg 3272b4bb05 feat: landing page analytics goals and sticky pay button
- Add sticky_pay_button, analytics_view_enabled, analytics_view_goal,
  analytics_click_enabled, analytics_click_goal columns to LandingPage
- Add fields to CRUD updatable fields, admin create/update/detail
  schemas, create_landing() kwargs, _landing_to_detail() response
- Expose sticky_pay_button and analytics fields in public landing
  config response for frontend Yandex Metrika integration
- Add migration 0062 with idempotent column checks

Based on PR #2852 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)
2026-04-22 05:36:41 +03:00
Fringg dd177101f7 fix: ticket media_items review fixes
- Add if has_media else None guards in user-side ticket handlers
  (create_ticket, add_message) matching admin handler pattern
- Fix Telegram notification using resolved primary_file_id/primary_type
  instead of raw request fields for gallery messages
- Narrow except Exception to (TypeError, KeyError, ValueError) in
  _message_to_response with warning log for debugging
- Add media_items parameter to TicketCRUD.create_ticket and
  TicketCRUD.add_message for CRUD layer parity
- Add TicketMediaItemResponse and media_items field to webapi
  TicketMessageResponse to prevent data loss on read
2026-04-22 05:29:51 +03:00
Fringg 36571c4275 feat(tickets): multi-media message gallery (media_items JSONB)
- Add media_items JSONB column to TicketMessage model for multi-media
  gallery support (photos/videos/documents in one bubble)
- Add TicketMediaItem schema with type validation and shared
  _validate_media_bundle helper (max 10 items, legacy field compat)
- Update admin and user ticket handlers to store media_items and
  back-fill legacy media_type/media_file_id/media_caption from first
  item for backward compatibility
- Update _message_to_response in both admin and user routes to include
  media_items in API responses
- Allow empty message text when media is attached (message field now
  defaults to empty string with model validator ensuring text or media)
- Add migration 0061 with idempotent column check

Based on PR #2869 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)
2026-04-22 05:23:48 +03:00
Fringg 66f8577448 fix: menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports
- Increase icon max_length from 10 to 100 in all three schemas
  (MenuButtonConfig, ButtonUpdateRequest, AddCustomButtonRequest)
  to support Telegram Custom Emoji IDs
- Add traffic_topup_enabled condition to ButtonConditions schema
- Remove shadowing local imports of MenuLayoutService in
  routes/menu_layout.py (top-level import already provides access)
2026-04-22 05:17:56 +03:00
Fringg 9ca3320a02 fix: classic mode renewal resets device_limit to 1 via cart key mismatch
- Fix cart key mismatch: extend cart saved 'device_limit' but
  confirm_purchase read 'devices' key, falling back to DEFAULT=1.
  Now both keys are saved in both cart-save paths
- Fix confirm_purchase device resolution: use explicit is None checks
  instead of or-chain to avoid falsy-zero trap
- Fix return_to_saved_cart display: fall back to 'device_limit' and
  'traffic_limit_gb' keys when 'devices'/'traffic_gb' are absent
- Fix second cart-save path in _extend_existing_subscription with
  same dual-key pattern
- Fix RemnaWaveService import path in renewal service
- Add RESET_DEVICES_ON_RENEWAL setting: resets all connected devices
  (hwid) via RemnaWave API on each subscription renewal
2026-04-22 05:12:27 +03:00
Fringg 9ed4f086b0 feat: tariff switch direction control, fix device pricing within tariff limit
Tariff switch direction:
- Add TARIFF_SWITCH_UPGRADE_ENABLED and TARIFF_SWITCH_DOWNGRADE_ENABLED
  settings to control allowed switch directions
- Guard all 10 entry points: instant switch (list, preview, confirm),
  legacy switch (list, select, confirm, daily confirm), cabinet (preview,
  execute), purchase-options API
- Filter tariff lists by allowed direction, show "unavailable" when
  both directions disabled
- Expose settings in cabinet purchase-options response for frontend

Device pricing fix:
- Devices within tariff.device_limit are now free when restoring
  (was charging for all devices regardless of tariff inclusion)
- Fix max(100, price) minimum enforcing 1 RUB even when
  chargeable_devices is 0
- Apply fix across all endpoints: bot handlers (confirm_change,
  execute_change, confirm_add), cabinet API (legacy purchase,
  modern purchase, get-price, save-cart), inline keyboard display
2026-04-22 04:54:17 +03:00
Fringg da855a7c89 fix: tariff switch pricing showing free for upgrades, admin duplicate subscription guard
- pricing_engine: use shortest period for daily rate comparison instead
  of period closest to remaining_days — fixes incorrect free/zero cost
  for upgrades when tariffs have different period sets
- pricing_engine: remove unused target_days parameter from
  get_tariff_daily_rate_fraction
- admin_users: add duplicate subscription check before create,
  change_tariff and activate actions to prevent UniqueViolationError
  on uq_subscriptions_user_tariff_active constraint
- admin_users: add IntegrityError fallback on create as TOCTOU safety net
2026-04-22 04:25:42 +03:00
Fringg 7be404b918 fix: FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal
- balance/platega: re-set FSM state after min/max validation errors,
  set state before pending_amount path, use balance_topup callback for back button
- balance/main: set FSM state and payment_method in handle_topup_amount_callback
  for all providers before routing, use balance_topup callback in validation errors
- payment/paypear: fix confirmation_url key (was 'url'), add fallback,
  store charged amount with commission for correct webhook amount comparison
- tariff_purchase: redirect to active tariff list when current tariff is
  inactive (hidden trial after promo code activation)
- cabinet/renewal: check tariff.is_active in both GET and POST endpoints
  to prevent hidden trial tariff periods from appearing
2026-04-22 04:05:46 +03:00
Dmitry V. Lunin b71e58c8d2 fix: do not reset subscription_crypto_link when cryptoLink absent in webhook (#2891)
Co-authored-by: Dmitry Lunin <br@slack.ru>
2026-04-21 08:05:11 +03:00
Dmitry V. Lunin 1696e6f884 fix: do not update first_name/last_name from OIDC claims (#2892)
Co-authored-by: Dmitry Lunin <br@slack.ru>
2026-04-21 08:04:00 +03:00
Dmitry V. Lunin 7093d368d3 feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook (#2894)
* feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook

* style: ruff format main.py

---------

Co-authored-by: Dmitry Lunin <br@slack.ru>
2026-04-21 08:02:53 +03:00
Danila Yudin 905cea68b4 fix: grant all available squads for unrestricted trials (#2897) 2026-04-21 08:01:28 +03:00
Egor dc5442223d Merge pull request #2898 from BEDOLAGA-DEV/main
w
2026-04-21 07:53:33 +03:00
c0mrade 3b03c253cc fix: устранить root cause MissingGreenlet в автоплатежах через refetch по id
Трейс показал: subscription.user падает на lazy-load → pool._checkout →
do_ping → await_ → MissingGreenlet. SQLAlchemy 2.0 async session не
поддерживает sync-lazy-load для relationships. Причина рассинхрона:
lock_user_for_pricing делает populate_existing=True + selectinload(
User.subscriptions).selectinload(Subscription.tariff), что разгружает
Subscription.user backref для сестринских подписок того же user.
Последующее обращение sub.user у другой подписки падает.

Фикс: захватываем (sub_id, user_id) пары ДО цикла, каждую итерацию
делаем fresh refetch через async select с eager load user+tariff+
promo_group. Никаких lazy access в горячем пути. В except используем
локально захваченные id вместо getattr(subscription, ...), чтобы
логирование не падало каскадом на expired объекте.
2026-04-19 12:08:34 +03:00
c0mrade db79cc9eb0 fix: устранить MissingGreenlet в автоплатежах и починить traceback в логах
- subtract_user_balance: пишем promo_offer_log в отдельной сессии вместо rollback после commit, который экспайрил объекты основной сессии и ломал последующие обращения к subscription/user attrs
- monitoring_service._process_autopayments: перезагружаем subscription с eager-load user/tariff после списания, оборачиваем каждую итерацию в try/except + rollback, чтобы одна ошибка не валила весь батч
- logging_config: новый processor _auto_capture_exc_info автоматически подтягивает traceback из sys.exc_info() или error-kwarg → полный traceback в файле, консоли и Telegram без exc_info=True на каждом вызове
- logging_handler: дублирующая логика захвата exc_info в TelegramNotifierProcessor как резерв
2026-04-19 11:50:40 +03:00
Egor 1b94d9e700 Merge pull request #2890 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.49.0
2026-04-18 04:00:56 +03:00
github-actions[bot] 39a7c92cd4 chore(main): release 3.49.0 2026-04-18 00:58:34 +00:00
Egor 81ebec676c Merge pull request #2889 from BEDOLAGA-DEV/dev
Dev
2026-04-18 03:58:10 +03:00
Fringg 25ea5c60fd docs: add AuraPay to README with partner block 2026-04-18 03:56:48 +03:00
Fringg 29877fc93b fix: handle edge case when all tariffs are daily in legacy renewal 2026-04-18 01:09:53 +03:00
Fringg 5986c00fab fix: redirect legacy users without tariff to tariff selection on renewal
Users created before tariffs were introduced (tariff_id=NULL) got
"Тариф не найден" when pressing "Продлить подписку". Now they see
a tariff selection list instead, allowing them to pick a tariff
and renew with proper parameters (traffic, devices, etc).
2026-04-18 01:05:48 +03:00
Fringg ecc4a6147d fix: rate-limit daily subscription insufficient balance notifications to 6 hours
Users with daily subscriptions and low balance were getting
"Подписка приостановлена" notification every 30 minutes (on each
charge cycle). Now rate-limited via Redis cache to max 1 notification
per 6 hours per subscription.
2026-04-18 00:59:09 +03:00
Fringg 16bc1d4198 fix: align campaign top registrations revenue with period comparison
Top registrations list was summing DEPOSIT + SUBSCRIPTION_PAYMENT
transactions (total user spending), while period comparison revenue
only counted DEPOSIT with real payment methods (actual money paid in).

Now both use the same calculation: only DEPOSIT transactions with
real payment methods. This fixes the discrepancy where a user showed
500₽ in the list but total revenue was 250₽.
2026-04-18 00:50:23 +03:00
Fringg 0f814be1b7 fix: add missing RollyPay CRUD wrappers and guest payment flow
RollyPay was missing:
- 7 CRUD wrapper functions in payment_service.py (create, get, update, link)
- Guest payment block in create_guest_payment()

Both were present for PayPear and AuraPay but omitted for RollyPay.
2026-04-18 00:23:38 +03:00
Fringg 97179360c0 feat: integrate AuraPay payment provider
Full integration following PayPear/RollyPay patterns:
- API client with X-ApiKey + X-ShopId auth, HMAC-SHA256 webhook verification
  (sorted keys, concatenated values, secret key #2)
- AuraPayPaymentMixin with create, webhook, finalize (all side effects), status check
- CRUD, model, migration (0060), PaymentMethod.AURAPAY enum
- Webhook endpoint, cabinet topup, bot handler with sub-options (card, sbp)
- Admin panel: AURAPAY category in bot_configuration + system_settings_service
- Config: AURAPAY_ENABLED, AURAPAY_API_KEY, AURAPAY_SHOP_ID, AURAPAY_SECRET_KEY
2026-04-18 00:02:49 +03:00
Fringg 2aa5927433 fix: register PayPear and RollyPay in admin panel settings
- bot_configuration.py: added PAYPEAR/ROLLYPAY to payment categories
  and test payment buttons
- system_settings_service.py: added category titles, descriptions,
  and prefix mappings for PAYPEAR_* and ROLLYPAY_* settings
2026-04-17 23:44:35 +03:00
Egor 1c696c69e3 Merge pull request #2887 from BEDOLAGA-DEV/dev
docs: add PayPear and RollyPay to README with partner blocks
2026-04-16 06:26:19 +03:00
Fringg b531959982 docs: add PayPear and RollyPay to README with partner blocks 2026-04-16 06:25:33 +03:00
164 changed files with 20432 additions and 822 deletions
+33 -6
View File
@@ -254,6 +254,18 @@ WEBHOOK_NOTIFY_DEVICES=true
# - Подходит для продажи готовых пакетов услуг
SALES_MODE=tariffs
# Управление сменой тарифа (для SALES_MODE=tariffs)
# UPGRADE / DOWNGRADE:
# true / true = все направления разрешены
# true / false = только повышение (на более дорогой тариф)
# false / true = только понижение (на более дешёвый тариф)
# false / false = смена тарифа полностью отключена
TARIFF_SWITCH_UPGRADE_ENABLED=true
TARIFF_SWITCH_DOWNGRADE_ENABLED=true
# Сброс привязанных устройств при продлении подписки (однократно при каждом продлении)
RESET_DEVICES_ON_RENEWAL=false
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
@@ -730,6 +742,24 @@ ROLLYPAY_WEBHOOK_PATH=/rollypay-webhook
# URL возврата после оплаты
# ROLLYPAY_RETURN_URL=
# ===== AURAPAY (aurapay.tech) =====
AURAPAY_ENABLED=false
# API ключ (X-ApiKey header)
AURAPAY_API_KEY=
# UUID магазина (X-ShopId header)
AURAPAY_SHOP_ID=
# Секретный ключ #2 для HMAC-SHA256 верификации вебхуков
AURAPAY_SECRET_KEY=
AURAPAY_DISPLAY_NAME=AuraPay
AURAPAY_CURRENCY=RUB
AURAPAY_MIN_AMOUNT_KOPEKS=10000
AURAPAY_MAX_AMOUNT_KOPEKS=10000000
AURAPAY_WEBHOOK_PATH=/aurapay-webhook
# URL возврата после оплаты
# AURAPAY_RETURN_URL=
# Время жизни инвойса в минутах
AURAPAY_PAYMENT_LIFETIME_MINUTES=60
# ===== WATA =====
WATA_ENABLED=false
WATA_BASE_URL=https://api.wata.pro
@@ -956,6 +986,9 @@ DEBUG=false
WEBHOOK_URL=
WEBHOOK_PATH=/webhook
WEBHOOK_SECRET_TOKEN=
# IP адрес сервера для setWebhook — Telegram будет использовать его напрямую без DNS резолва домена
# Необходимо в регионах где Telegram не может резолвить домены (РФ и др.)
# WEBHOOK_IP=
WEBHOOK_DROP_PENDING_UPDATES=true
WEBHOOK_MAX_QUEUE_SIZE=1024
WEBHOOK_WORKERS=4
@@ -996,10 +1029,4 @@ WEB_API_TOKEN_HASH_ALGORITHM=sha256
# Логирование запросов
WEB_API_REQUEST_LOGGING=true
# Внешний админ-токен (для интеграции с другими ботами/системами)
# Токен для доступа через API другого бота
# EXTERNAL_ADMIN_TOKEN=
# ID бота, от которого принимается токен
# EXTERNAL_ADMIN_TOKEN_BOT_ID=
MINIAPP_STATIC_PATH=miniapp
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.48.0"
".": "3.54.0"
}
+193
View File
@@ -1,5 +1,198 @@
# Changelog
## [3.54.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.53.0...v3.54.0) (2026-05-04)
### New Features
* add subscription reissue with 15-min cooldown ([1ab1ff9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ab1ff90bf6fc243da45a53b2d549464e47c2f31))
* Apple IAP integration with security hardening ([ecde2fb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ecde2fb8f05620889f45fea4410e9069b99340a5))
* integrate Antilopay payment provider (API v2) ([7196642](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/719664208e94fe1e528ff0f85f500e5b96555dc9))
* integrate Etoplatezhi payment provider ([6524f66](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6524f66da2717001aba99831f8dec93ad8f6e6f8))
* integrate Jupiter (FPGate P2P) and Donut payment providers ([f321ded](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f321ded9c0033ff3848a92aef44cbfef7f8d3883))
* integrate Lava Business payment provider ([afea054](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/afea054c8f8898d076d667c2d17248052578cbce))
### Bug Fixes
* add pycryptodome dependency for Antilopay RSA signing ([3fce648](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3fce64858cc4e536ab32acc4c68213c208b6081c))
* apple refund handler — lock apple_transactions row to prevent double deduction ([e85c40f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e85c40f8cdca1e3392da06f6539f0043f923b731))
* AuraPay webhook signature + add SBP/Card payment method selection ([17ac3da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17ac3da3c4da0f36015e338451a22a1a7a433524))
* expired_1d notification — use PricingEngine instead of hardcoded PRICE_30_DAYS ([2478ff7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2478ff7c3d0c426c5f64dec71d17df13de60702a))
* guide mode buttons — support external type alias, extract urlScheme from blocks ([2385814](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2385814d77c6b847fb0df248b5aaaa2ff27fd20e))
* persist campaign across bot→webapp registration handoff via Redis ([99648a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/99648a956e20cb99d406455db2f26db469ba7235))
* register all providers in payment search and verification services ([cd8be32](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cd8be326716e2c3253d9b2935da1a7927725c9af))
* **tests:** clean up pre-existing ruff lint warnings in apple_iap tests ([0d06467](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0d0646770d2a1cdccec14ea3b78dc12412f95d0a))
## [3.53.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.52.1...v3.53.0) (2026-04-29)
### New Features
* add bulk_actions, info_pages, news to PERMISSION_REGISTRY ([06db393](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/06db3934881bc55851e1ff171fca89abc7deebe5))
* per-category enable/disable for admin notifications ([fb857d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fb857d792b7dd6658df7c081ef46b4cc729cd2fa))
* protect active paid subscriptions from bulk delete ([51dfc3a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/51dfc3a1a2a31706b5c307f6394f2ef9f578cc51))
* subscription request history API + RemnaWave panel method ([e22beb7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e22beb722934779a6d10cb9a7c9a1853f68f7787))
### Bug Fixes
* add 5 missing payment providers to pending-payments model_map ([a506c6b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a506c6be004054998354286fc9dc3990aa867ccc))
* add 6 missing payment providers to payment_utils availability checks ([83efc21](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/83efc214fef7cdec438cf39e4301e6d22bceec7e))
* add RollyPay, PayPear, Overpay, AuraPay to REAL_PAYMENT_METHODS ([ff7b190](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff7b1905271e92625c10fa22d809f27c22a496b6))
* autopay renewing trial subscriptions at classic-mode pricing ([2fbdbf5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2fbdbf5ab0ec0c13100e330f60a2249ed866c12e))
* backup completeness — add 15 missing tables, accept .tar.gz uploads ([eafb243](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eafb243882a2f325771e397cdcc3b258f8ec8f7a))
* backup import crash + upload handler hardening ([5cf19c7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5cf19c76e6fa9456cee0157ac4a4c0742d0f7718))
* block/unblock endpoints — correct args, response schema, panel sync ([0bcb804](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0bcb804118fa9cb02c580eea849b6cd218d931f8))
* cabinet /block endpoint now disables panel user in RemnaWave ([735e16a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/735e16afeba70ef22e690d668e6693cdd2bac140))
* cabinet autopay endpoint — same NULL-safe is_trial guard ([29e177d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29e177d396796827e3970015afb5a84512612035))
* cabinet purchase fails after panel user deletion — stale UUID ([f37eb9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f37eb9a1bd6a149f38458b1a4a1071efd8c03660))
* callback.answer() before heavy operations to prevent query timeout ([579e4f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/579e4f2a695315091db7f014d6a2241852b078ae))
* create locales directory with correct permissions in Dockerfile ([74999fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/74999fe99dd5ef7b10814cb24ff3022aca1d1c0d))
* deadlock on user deletion — webhook handler never checked intentional mark ([62e7ecb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/62e7ecba01601ab3d5aa6c913db064d5fa768d9a))
* device/traffic addon pricing — use ceil instead of floor for days_left ([cf60ae2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf60ae2967b8a5b0c42c51354e3a2513c38e7120))
* downgrade Pal24 API validation errors from error to debug ([c905fa6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c905fa60000c238475cdc3b253ae6e67aa670ff4))
* false subscription expiry notifications — 4 bugs fixed ([134e7fb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/134e7fb0e1999f6404e2d06f38aebdb6af550ec1))
* handle A018 error code as user-not-found fallback to create_user ([c619dbc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c619dbcae2a1470d132cdd35cb9824ac801f117c))
* handle A018 error code in admin_users sync endpoints (2 more locations) ([59080f7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59080f7392412cae848fdd3887cb25304a3a33f9))
* media upload leaks staging photo to admin chat ([1110d0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1110d0c7810d52d4f8f789dc5383cf4a0f5fce96))
* panel sync subscription duration — ceil for days_remaining ([527c5b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/527c5b4498972e2806480af1d0625939eae50bee))
* PayPear webhook signature — strip signature field before hashing + IP fallback ([443a826](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/443a826402e63b021f1efc856257bbf54c79fdb4))
* PollResponse has no created_at — use sent_at for ordering ([768e0b6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/768e0b6a7363e8b496f1e1b83fe567993dc664da))
* promo group discount applied to restricted tariffs in autopay ([4ab5928](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4ab5928b61c35b4af115ced949e373c3373c7145))
* referral links now clickable — remove &lt;code&gt; wrapping ([43dd0fd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43dd0fd92c433498d780ca4e8798d606f5f70dbf))
* remaining pricing-critical .days floor calculations → math.ceil ([bada41e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bada41ecd67e81020627dc50e90d357c088471ab))
* replace all late callback.answer() with edit_text for error feedback ([c743fc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c743fc81a5cb56153dbfe49e4f293277d773e948))
* retry queue action uses _should_create instead of stale subscription UUID ([422844d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/422844d78df2865926c18d888edfb95e72c077c7))
* send admin notification on promo code activation from cabinet ([1c38b31](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1c38b31e60f2491c2366f43b792400af71cba70d))
* subscription-request-history — correct API client usage, add ownership check ([1fc04d8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1fc04d842fe589df1ec6208d6709b5bd55c85da7))
* traffic addon discount also bypassed tariff-promo-group check ([47c7d45](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/47c7d4579336833213203f3e127d4155ed25b555))
* traffic addon price mismatch — keyboard showed prorated, handler charged full month ([a88e3c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a88e3c80ade1a1178270efe5e4100810da6f79b5))
* update cabinet_last_login on every request (throttled, 5 min) ([91de6d0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91de6d03fce4d6082359e7a1ba7e7f5ec02756b6))
### Refactoring
* remove unused EXTERNAL_ADMIN_TOKEN functionality ([b9b6957](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b9b695799cb15f0f3be0116063cbf72482096b5d))
## [3.52.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.52.0...v3.52.1) (2026-04-24)
### Bug Fixes
* broadcast preview count — add .correlate(User) to EXISTS subqueries ([63e1127](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/63e112735302cc5fc961b2da87fb1250f922fe3d))
* ignore bot's own messages in unknown message handlers ([52bf2a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/52bf2a9589e3e14abda78a62e45e86e022da4994))
* unpin messages in Telegram BEFORE deactivating in DB ([ab4661b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ab4661b5c643d6dad787cc499c5cff128ec21be5))
* use fresh DB session for deactivate after long unpin loop ([5ed9a0d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ed9a0d4fbef5c92bfb4e7eb0daed493805c5e44))
## [3.52.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.51.0...v3.52.0) (2026-04-24)
### New Features
* admin bulk actions API — mass operations on users ([fb2773f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fb2773fee4f36fc517624d96243b731c94a3508d))
* bulk actions — campaign/partner filters, delete_user action ([d77fd81](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d77fd81e161a65ba848133ba4798a6a335e35c1c))
* bulk actions — SSE streaming progress, grant subscription, multi-tariff info ([c0e0756](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c0e0756b9ac15cd926708be3f2f9c93e571ad411))
* bulk delete_subscription action — removes from bot DB + RemnaWave ([605f202](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/605f202191d0203fdc7f5ea3bb0695ece47c9cd6))
* bulk set_devices action + device info in subscription list ([be787a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be787a85bfd981b70bd347c943f5cef8ec0610fc))
* FAQ support in info pages — page_type field + migration ([d394565](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d394565fe940549d1211a1ddab37f4d002d43dd5))
* info page tab replacement — replaces_tab field + API ([bdb8cab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bdb8cab1c97328ff05218a9a68612418c69ccacf))
* information pages — CRUD model, admin API, public API ([e4b4a54](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4b4a54797875b697146f8b804b3bbdfff4eb78b))
* multi-tariff bulk actions — subscription-level targeting ([e78177b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e78177b2fc34c895ab2b74705516af2a3fcbfb70))
* support multiple tariff_ids in user list filter ([0d2b1df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0d2b1dfdc97d2f3058ef793c89beaa8da5709a4a))
### Bug Fixes
* /reorder route unreachable — move before /{page_id} path param ([122d12d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/122d12db20537cf0ad55cea5328f03aa3464b540))
* add subscription/tariff/promo_group filters to admin user list API ([daa4725](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/daa472570ccf6ef50441e0e173ddd1b87fe31e5e))
* always return subscriptions list in user list API ([cfbcc30](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cfbcc3082fca3a6101604aea197e7844974ed15d))
* bulk actions review — rollback on error, multi-tariff constraint checks ([5b45d43](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b45d4354f6e58bd609e69a45ef100acfed3dbf4))
* bulk change_tariff not clearing squads when new tariff has none ([db7b673](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db7b6734fdcae52397a25eb94e1b24e908fcc41c))
* bulk delete_user — pass real admin_id, sanitize error messages ([2e45a93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2e45a93bd72c7e930fad60f7be2292596271c0dc))
* gift purchases no longer inflate promo group level ([1522d35](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1522d35f2dec0e35153c452818380f9b41b35f1d))
* info pages review — deduplicate slug index, type reorder items ([2071a68](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2071a680d3d569f67c447921370463f13c928c55))
* integrate Yandex Metrika offline conv + S2S postback hooks ([7d512d2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d512d214a1ca184ba6601bf1ee5a39727ed41e5))
* MissingGreenlet in subscription-ids bulk actions ([2ad893b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2ad893badfec6f782ed89746b1cf2d3d7b07aa3a))
* privacy policy and offer text display HTML links as plain text ([59c54c9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59c54c9b39d9cb26ed518cc74c93e1da43ce2020))
* restore HTTP Referer fallback for landing purchases ([24dc8d2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/24dc8d2a5e1be5239036acf5888acfe1e8e96444))
* restore referrer field in admin landing purchases response ([2cde38c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cde38c63b35e52a552bf58c003a8281525a0be7))
* sanitize error messages in all bulk action catch-all handlers ([9217f41](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9217f4116f74d15133531a711cb2bc083f30dd49))
* server squad sync fails on fresh DB without default promo group ([ae7feeb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ae7feeb726ab0a9b3074c9ed11fd45e2cc412ea0))
* stop printing tracebacks for warning-level logs inside except blocks ([68d2350](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68d2350dfdf2f1f6964dd744a447b558a1d06267))
* suppress 'User already enabled' traceback in bulk add_traffic ([ff41ea9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff41ea9abbdbeb6746e23cde48a5f962b289fc48))
### Documentation
* add Overpay to README with partner block ([bcf5519](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bcf5519880471d113833a5031cae54594e658a7b))
* add Overpay to README with partner block ([70568f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/70568f82c5c6c2c1041865c7109907fa9e2b8c6a))
## [3.51.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.50.0...v3.51.0) (2026-04-23)
### New Features
* integrate Overpay payment provider (pay.overpay.io) ([2c3ffc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2c3ffc8c8a9e6591d2356320ed0a71ab5db6cbec))
* respond to unknown media messages (photos, videos, documents) ([29ae708](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29ae7089aa0a8a5c7ddd73622a492df6b5e67e03))
### Bug Fixes
* inactive user cleanup deletes users with paid subscriptions ([7005052](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/70050521566ab86b75fb4f64ed3b61bced5d1612))
* pad short RemnaWave usernames to meet 3-char minimum ([6f87563](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6f875637899544d95daf8d87a30042b5347f6b41))
## [3.50.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.49.0...v3.50.0) (2026-04-22)
### New Features
* add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook ([#2894](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2894)) ([7093d36](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7093d368d3356c692bff3eddda68996579bfd036))
* landing page analytics goals and sticky pay button ([3272b4b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3272b4bb053c76c3995cd3904b085a28f741e815))
* tariff switch direction control, fix device pricing within tariff limit ([9ed4f08](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ed4f086b0102c20d7861e03f4d212ff57e28245))
* **tickets:** multi-media message gallery (media_items JSONB) ([36571c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/36571c4275b76fa0bdb490ffccacc0a0e32e9bd6))
* v3.50.0 release ([a491fe3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a491fe34bda6da86d72891fe4d05b6abd169cf2f))
* Yandex Metrika offline conversions + S2S postbacks ([1068c13](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1068c1387a03bf7c94c7b29f9bb61acc3e3e782a))
### Bug Fixes
* classic mode renewal resets device_limit to 1 via cart key mismatch ([9ca3320](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ca3320a0204346aac2c76601f221b0bc70226a7))
* do not reset subscription_crypto_link when cryptoLink absent in webhook ([#2891](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2891)) ([b71e58c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b71e58c8d25a5c94c94a401a59359cab4870e3c0))
* do not update first_name/last_name from OIDC claims ([#2892](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2892)) ([1696e6f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1696e6f8843ceaa1a3e9a6d536e0d99968e579b1))
* FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal ([7be404b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7be404b918640dcccdcda82802bb02a342b730d6))
* grant all available squads for unrestricted trials ([#2897](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2897)) ([905cea6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/905cea68b48b596412d83c42de0d1dce77dcaee0))
* menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports ([66f8577](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/66f8577448712786d851b1802c4a183e7acc1779))
* tariff switch pricing showing free for upgrades, admin duplicate subscription guard ([da855a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da855a7c8955dcbf3543dbbaedc00e16b9c35193))
* ticket media_items review fixes ([dd17710](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dd177101f7314c5d25120a69e04c66d74f37b18f))
* validate analytics goal is set when analytics is enabled on landing ([d316325](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d31632534b3e1b2268a8f8b2a441214f377f23eb))
* устранить MissingGreenlet в автоплатежах и починить traceback в логах ([db79cc9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db79cc9eb0d7dc7a4a1ae9190f7a23c6c9e6e317))
* устранить root cause MissingGreenlet в автоплатежах через refetch по id ([3b03c25](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3b03c253cc1603fe54fddcbc8bc374ec2c71bdfc))
## [3.49.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.48.0...v3.49.0) (2026-04-18)
### New Features
* integrate AuraPay payment provider ([9717936](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97179360c0288940b1fa2f6c21a6e1431a27536f))
### Bug Fixes
* add missing RollyPay CRUD wrappers and guest payment flow ([0f814be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0f814be1b7dfaec84dde9acc402b2c1790417611))
* align campaign top registrations revenue with period comparison ([16bc1d4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/16bc1d41989d66e105724ed846fb40ccf03322fd))
* handle edge case when all tariffs are daily in legacy renewal ([29877fc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29877fc93bc612ee199ccb2438c90e57a3c1e9e0))
* rate-limit daily subscription insufficient balance notifications to 6 hours ([ecc4a61](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ecc4a6147dad0c8886acd48f38e567a6c7fc8916))
* redirect legacy users without tariff to tariff selection on renewal ([5986c00](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5986c00fab8c5fe2060d296afca72d28038ff7bd))
* register PayPear and RollyPay in admin panel settings ([2aa5927](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2aa59274331610eec3cd84f90ddb49ee59da22ef))
### Documentation
* add AuraPay to README with partner block ([25ea5c6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25ea5c60fdaf3cac9294b6df74142c176b1d4d04))
* add PayPear and RollyPay to README with partner blocks ([1c696c6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1c696c69e34e668ff90c915249b7c3dc37bfd89b))
* add PayPear and RollyPay to README with partner blocks ([b531959](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b53195998231d34b6e1c7165193e87fee6e5c293))
## [3.48.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.47.0...v3.48.0) (2026-04-16)
+3 -3
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.48.0" # x-release-please-version
ARG VERSION="v3.54.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
@@ -33,8 +33,8 @@ WORKDIR /app
COPY --chown=app:app . .
RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails && \
chown -R app:app logs data uploads
RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails locales && \
chown -R app:app logs data uploads locales
USER app
+87 -2
View File
@@ -55,7 +55,7 @@ Bedolaga — полнофункциональная платформа для п
### 💳 Платежи
- 🏦 **15 платёжных провайдеров** одновременно
- 🏦 **24+ платёжных провайдера** одновременно
- 💰 Единый баланс: пополнение любым способом → покупка с баланса
- ⚡ Автопокупка подписки после пополнения
- 💾 Рекуррентные платежи (сохранённые карты)
@@ -123,6 +123,16 @@ Bedolaga — полнофункциональная платформа для п
| 💳 | **MulenPay** | Карты | RUB |
| 💳 | **RioPay** | Карты | RUB |
| 💳 | **SeverPay** | СБП, карты | RUB |
| 🤝 | **[PayPear](https://t.me/Paymen1_Manager)** 🔸 | Карты, СБП, SberPay, T-Pay | RUB |
| 🤝 | **[RollyPay](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)** 🔸 | СБП, карты, крипто | RUB → USDT |
| 🤝 | **[AuraPay](https://aurapay.tech/)** 🔸 | Карты, СБП | RUB |
| 🤝 | **[Overpay](https://overpay.pro/)** 🔸 | Карты, СБП | RUB |
| 🦌 | **Antilopay** | Карты, СБП, SberPay (RSA подпись) | RUB |
| 💳 | **Etoplatezhi** | Карты, СБП | RUB |
| 🪐 | **[Jupiter](https://t.me/k_juppiter)** 🔸 | СБП через QR (FPGate P2P v2.1) | RUB |
| 🍩 | **[Donut](https://t.me/donut_payment)** 🔸 | Карты, СБП по телефону, СБП QR (P2P) | RUB |
| 🌋 | **Lava Business** | Карты, СБП (gate.lava.ru) | RUB |
| 🍎 | **Apple In-App Purchase** | Покупки через iOS App Store | USD |
| 📲 | **Tribute** | Telegram-платежи | RUB |
</div>
@@ -157,6 +167,81 @@ Bedolaga — официальный партнёр платёжной систе
📩 По вопросам: [@wyrz_wata](https://t.me/wyrz_wata)
</td>
</tr>
<tr>
<td align="center">
**🤝 Официальный партнёр PayPear**
Bedolaga — официальный партнёр платёжной системы **[PayPear](https://paypear.ru)**.<br>
Банковские карты, СБП, SberPay и T-Pay — всё через единый API.<br>
Подключение по **спец. условиям** через кодовое слово **`БЕДОЛАГА`**
📩 Менеджер: [@Paymen1_Manager](https://t.me/Paymen1_Manager)
</td>
<td align="center">
**🤝 Официальный партнёр RollyPay**
Bedolaga — официальный партнёр платёжного шлюза **[RollyPay](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)**.<br>
СБП (от 5%), банковские карты РФ, крипто, вывод в USDT.<br>
Универсальная форма оплаты, высокая проходимость, стабильная работа в каскаде.<br>
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
📩 Менеджер: [@rollypay_manager](https://t.me/rollypay_manager) | 🌐 [rollypay.io](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)
</td>
</tr>
<tr>
<td align="center">
**🤝 Официальный партнёр AuraPay**
Bedolaga — официальный партнёр платёжной системы **[AuraPay](https://aurapay.tech/)**.<br>
Банковские карты и СБП через единый API с быстрой интеграцией.<br>
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
📩 Менеджер: [@kickdownm](https://t.me/kickdownm) | 🌐 [aurapay.tech](https://aurapay.tech/)
</td>
<td align="center">
**🤝 Официальный партнёр Overpay**
Bedolaga — официальный партнёр платёжного шлюза **[Overpay](https://overpay.pro/)**.<br>
Банковские карты и СБП, mTLS-авторизация, HPP-интеграция.<br>
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
📩 Менеджер: [@A_OverPay](https://t.me/A_OverPay) | 🌐 [overpay.pro](https://overpay.pro/)
</td>
</tr>
<tr>
<td align="center">
**🤝 Официальный партнёр Jupiter (FPGate P2P)**
Bedolaga — официальный партнёр платёжного шлюза **Jupiter** (FPGate P2P v2.1).<br>
Эквайринг СБП через QR-код банковского приложения, HMAC-SHA256 подпись.<br>
Высокая проходимость, callback-driven архитектура, защита от replay-атак.<br>
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
📩 Менеджер: [@k_juppiter](https://t.me/k_juppiter)
</td>
<td align="center">
**🤝 Официальный партнёр Donut**
Bedolaga — официальный партнёр платёжной системы **Donut** (Donut P2P).<br>
P2P-оплата картой, СБП по номеру телефона и СБП QR — три метода через единый API.<br>
HMAC-SHA256 подпись, sticky terminal-status guard, защита от amount tampering.<br>
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
📩 Менеджер: [@donut_payment](https://t.me/donut_payment)
</td>
</tr>
</table>
@@ -222,7 +307,7 @@ docker compose up -d
| | Раздел | Описание |
|:---:|:---|:---|
| 🚀 | [Быстрый старт](https://docs.bedolagam.ru/getting-started/quickstart) | Развёртывание за 5 минут |
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 14 провайдеров, webhook, фискализация |
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 24+ провайдера, webhook, фискализация, Apple IAP |
| 📦 | [Подписки и тарифы](https://docs.bedolagam.ru/bot/subscriptions) | Конфигурация планов и трафика |
| 👥 | [Реферальная программа](https://docs.bedolagam.ru/bot/referral-program) | Партнёрка и вывод средств |
| 🖥 | [Cabinet](https://docs.bedolagam.ru/cabinet/overview) | Настройка веб-кабинета |
+11
View File
@@ -1,5 +1,7 @@
"""FastAPI dependencies for cabinet module."""
from datetime import UTC, datetime
import structlog
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -176,6 +178,15 @@ async def get_current_cabinet_user(
},
)
# Throttled update of cabinet_last_login (at most every 5 minutes)
now = datetime.now(UTC)
if not user.cabinet_last_login or (now - user.cabinet_last_login).total_seconds() > 300:
try:
user.cabinet_last_login = now
await db.commit()
except Exception:
pass
return user
+18
View File
@@ -7,10 +7,12 @@ from .admin_apps import router as admin_apps_router
from .admin_audit_log import router as admin_audit_log_router
from .admin_ban_system import router as admin_ban_system_router
from .admin_broadcasts import router as admin_broadcasts_router
from .admin_bulk_actions import router as admin_bulk_actions_router
from .admin_button_styles import router as admin_button_styles_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_channels import router as admin_channels_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_info_pages import router as admin_info_pages_router
from .admin_landings import router as admin_landings_router
from .admin_menu_layout import router as admin_menu_layout_router
from .admin_news import router as admin_news_router
@@ -44,6 +46,7 @@ from .branding import router as branding_router
from .contests import router as contests_router
from .gift import router as gift_router
from .info import router as info_router
from .info_pages import router as info_pages_router
from .landing import router as landing_router
from .media import router as media_router
from .news import router as news_router
@@ -66,6 +69,13 @@ from .wheel import router as wheel_router
from .withdrawal import router as withdrawal_router
# Conditional imports
try:
from .apple_iap import router as apple_iap_router
except ImportError:
apple_iap_router = None
# Main cabinet router
router = APIRouter(prefix='/cabinet', tags=['Cabinet'], redirect_slashes=False)
@@ -78,6 +88,11 @@ router.include_router(subscription_router)
router.include_router(multi_tariff_subscription_router)
router.include_router(balance_router)
router.include_router(referral_router)
# Apple IAP routes
if apple_iap_router is not None:
router.include_router(apple_iap_router)
router.include_router(partner_application_router)
router.include_router(withdrawal_router)
# Notifications router MUST be before tickets router to avoid route conflict
@@ -93,6 +108,7 @@ router.include_router(branding_router)
router.include_router(landing_router)
router.include_router(media_router)
router.include_router(news_router)
router.include_router(info_pages_router)
# Wheel routes
router.include_router(wheel_router)
@@ -118,6 +134,7 @@ router.include_router(admin_campaigns_router)
router.include_router(admin_partners_router)
router.include_router(admin_withdrawals_router)
router.include_router(admin_users_router)
router.include_router(admin_bulk_actions_router)
router.include_router(admin_payment_methods_router)
router.include_router(admin_landings_router)
router.include_router(admin_payments_router)
@@ -140,6 +157,7 @@ router.include_router(admin_news_categories_router)
router.include_router(admin_news_tags_router)
router.include_router(admin_news_media_router)
router.include_router(admin_news_router)
router.include_router(admin_info_pages_router)
# WebSocket route
router.include_router(websocket_router)
File diff suppressed because it is too large Load Diff
+219
View File
@@ -0,0 +1,219 @@
"""Admin routes for managing info pages in cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.info_pages import (
clear_replaces_tab,
create_info_page,
delete_info_page,
get_all_info_pages,
get_info_page_by_id,
reorder_info_pages,
update_info_page,
)
from app.database.models import User
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.info_pages import (
InfoPageCreateRequest,
InfoPageListItem,
InfoPageResponse,
InfoPageUpdateRequest,
ReorderRequest,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/info-pages', tags=['Cabinet Admin Info Pages'])
@router.get('', response_model=list[InfoPageListItem])
async def list_all_info_pages(
page_type: str | None = Query(None, pattern=r'^(page|faq)$'),
admin: User = Depends(require_permission('info_pages:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> list[InfoPageListItem]:
"""Get all info pages (admin view, includes inactive)."""
try:
pages = await get_all_info_pages(db, include_inactive=True, page_type=page_type)
return [InfoPageListItem.model_validate(p) for p in pages]
except HTTPException:
raise
except Exception:
logger.exception('Failed to list info pages')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load info pages',
)
@router.get('/{page_id}', response_model=InfoPageResponse)
async def get_info_page_detail(
page_id: int,
admin: User = Depends(require_permission('info_pages:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Get a single info page by ID (admin view)."""
page = await get_info_page_by_id(db, page_id)
if not page:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
return InfoPageResponse.model_validate(page)
@router.post('', response_model=InfoPageResponse, status_code=status.HTTP_201_CREATED)
async def create_page(
request: InfoPageCreateRequest,
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Create a new info page."""
try:
if request.replaces_tab:
await clear_replaces_tab(db, request.replaces_tab)
page = await create_info_page(
db,
slug=request.slug,
title=request.title,
content=request.content,
page_type=request.page_type,
is_active=request.is_active,
sort_order=request.sort_order,
icon=request.icon,
replaces_tab=request.replaces_tab,
)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='An info page with this slug already exists',
)
except Exception:
logger.exception('Failed to create info page')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create info page',
)
return InfoPageResponse.model_validate(page)
@router.put('/{page_id}', response_model=InfoPageResponse)
async def update_page(
page_id: int,
request: InfoPageUpdateRequest,
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Update an existing info page."""
existing = await get_info_page_by_id(db, page_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
try:
update_data = request.model_dump(exclude_unset=True)
replaces_tab = update_data.get('replaces_tab')
if replaces_tab is not None:
await clear_replaces_tab(db, replaces_tab, exclude_page_id=page_id)
page = await update_info_page(db, page_id, **update_data)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='An info page with this slug already exists',
)
except Exception:
logger.exception('Failed to update info page', page_id=page_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update info page',
)
if not page:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found after update',
)
return InfoPageResponse.model_validate(page)
@router.delete('/{page_id}', status_code=status.HTTP_204_NO_CONTENT)
async def remove_page(
page_id: int,
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete an info page."""
existing = await get_info_page_by_id(db, page_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
try:
await delete_info_page(db, page_id)
except Exception:
logger.exception('Failed to delete info page', page_id=page_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to delete info page',
)
@router.post('/reorder', status_code=status.HTTP_204_NO_CONTENT)
async def reorder_pages(
request: ReorderRequest,
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Bulk update sort_order for info pages."""
try:
await reorder_info_pages(db, request.items)
except Exception:
logger.exception('Failed to reorder info pages')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to reorder info pages',
)
@router.post('/{page_id}/toggle-active', response_model=InfoPageResponse)
async def toggle_active(
page_id: int,
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Toggle the active status of an info page."""
existing = await get_info_page_by_id(db, page_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
try:
page = await update_info_page(db, page_id, is_active=not existing.is_active)
except Exception:
logger.exception('Failed to toggle info page active status', page_id=page_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to toggle active status',
)
if not page:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found after toggle',
)
return InfoPageResponse.model_validate(page)
+36
View File
@@ -205,6 +205,19 @@ class LandingCreateRequest(BaseModel):
discount_ends_at: datetime | None = None
discount_badge_text: dict[str, str] | None = None
background_config: dict | None = None
sticky_pay_button: bool = False
analytics_view_enabled: bool = False
analytics_view_goal: str | None = Field(default=None, max_length=64)
analytics_click_enabled: bool = False
analytics_click_goal: str | None = Field(default=None, max_length=64)
@model_validator(mode='after')
def validate_analytics_goals(self) -> 'LandingCreateRequest':
if self.analytics_view_enabled and not self.analytics_view_goal:
raise ValueError('analytics_view_goal is required when analytics_view_enabled is True')
if self.analytics_click_enabled and not self.analytics_click_goal:
raise ValueError('analytics_click_goal is required when analytics_click_enabled is True')
return self
@field_validator('background_config')
@classmethod
@@ -314,6 +327,11 @@ class LandingUpdateRequest(BaseModel):
discount_ends_at: datetime | None = None
discount_badge_text: dict[str, str] | None = None
background_config: dict | None = None
sticky_pay_button: bool | None = None
analytics_view_enabled: bool | None = None
analytics_view_goal: str | None = Field(default=None, max_length=64)
analytics_click_enabled: bool | None = None
analytics_click_goal: str | None = Field(default=None, max_length=64)
@field_validator('background_config')
@classmethod
@@ -461,6 +479,11 @@ class LandingDetailResponse(BaseModel):
discount_ends_at: datetime | None = None
discount_badge_text: dict[str, str] | None = None
background_config: dict | None = None
sticky_pay_button: bool = False
analytics_view_enabled: bool = False
analytics_view_goal: str | None = None
analytics_click_enabled: bool = False
analytics_click_goal: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
@@ -527,6 +550,7 @@ class LandingPurchaseItem(BaseModel):
currency: str
payment_method: str | None = None
status: str
referrer: str | None = None
created_at: datetime | None = None
paid_at: datetime | None = None
@@ -638,6 +662,11 @@ async def create_landing_page(
discount_ends_at=request.discount_ends_at,
discount_badge_text=request.discount_badge_text,
background_config=request.background_config,
sticky_pay_button=request.sticky_pay_button,
analytics_view_enabled=request.analytics_view_enabled,
analytics_view_goal=request.analytics_view_goal,
analytics_click_enabled=request.analytics_click_enabled,
analytics_click_goal=request.analytics_click_goal,
)
logger.info('Admin created landing page', admin_id=admin.id, slug=landing.slug, landing_id=landing.id)
@@ -979,6 +1008,7 @@ async def get_landing_purchases(
GuestPurchase.currency,
GuestPurchase.payment_method,
GuestPurchase.status,
GuestPurchase.referrer,
GuestPurchase.created_at,
GuestPurchase.paid_at,
)
@@ -1004,6 +1034,7 @@ async def get_landing_purchases(
currency=row.currency,
payment_method=row.payment_method,
status=row.status,
referrer=row.referrer,
created_at=row.created_at,
paid_at=row.paid_at,
)
@@ -1068,6 +1099,11 @@ def _landing_to_detail(landing: LandingPage) -> LandingDetailResponse:
discount_ends_at=landing.discount_ends_at,
discount_badge_text=landing.discount_badge_text,
background_config=landing.background_config,
sticky_pay_button=landing.sticky_pay_button,
analytics_view_enabled=landing.analytics_view_enabled,
analytics_view_goal=landing.analytics_view_goal,
analytics_click_enabled=landing.analytics_click_enabled,
analytics_click_goal=landing.analytics_click_goal,
created_at=landing.created_at,
updated_at=landing.updated_at,
)
+35 -14
View File
@@ -17,7 +17,7 @@ from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.database.models import Ticket, TicketMessage, User
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.tickets import TicketMessageResponse
from ..schemas.tickets import TicketMediaItem, TicketMessageResponse, _validate_media_bundle
logger = structlog.get_logger(__name__)
@@ -89,19 +89,19 @@ class AdminTicketListResponse(BaseModel):
class AdminReplyRequest(BaseModel):
"""Admin reply to ticket."""
message: str = Field(..., min_length=1, max_length=4000, description='Reply message')
message: str = Field(default='', max_length=4000, description='Reply message')
media_type: str | None = Field(None, description='Media type: photo, video, or document')
media_file_id: str | None = Field(None, max_length=255, description='Telegram file_id from media upload')
media_caption: str | None = Field(None, max_length=1000, description='Caption for media')
media_items: list[TicketMediaItem] | None = Field(None, description='Multi-media gallery attachments')
@model_validator(mode='after')
def validate_media_fields(self) -> 'AdminReplyRequest':
if self.media_file_id and not self.media_type:
raise ValueError('media_type is required when media_file_id is provided')
if self.media_type and not self.media_file_id:
raise ValueError('media_file_id is required when media_type is provided')
if self.media_type and self.media_type not in {'photo', 'video', 'document'}:
raise ValueError('media_type must be one of: photo, video, document')
_validate_media_bundle(self.media_type, self.media_file_id, self.media_items)
has_text = bool(self.message.strip())
has_media = bool(self.media_file_id) or bool(self.media_items)
if not has_text and not has_media:
raise ValueError('message or media is required')
return self
@@ -157,14 +157,23 @@ class TicketSettingsUpdateRequest(BaseModel):
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
"""Convert TicketMessage to response."""
raw_items = getattr(message, 'media_items', None) or None
items = None
if raw_items:
try:
items = [TicketMediaItem(**it) for it in raw_items]
except (TypeError, KeyError, ValueError) as exc:
logger.warning('Failed to parse media_items', message_id=message.id, error=str(exc))
items = None
return TicketMessageResponse(
id=message.id,
message_text=message.message_text or '',
is_from_admin=message.is_from_admin,
has_media=bool(message.media_file_id),
has_media=bool(message.media_file_id) or bool(items),
media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption,
media_items=items,
created_at=message.created_at,
)
@@ -455,17 +464,29 @@ async def reply_to_ticket(
detail='Ticket not found',
)
# Create admin message
has_media = bool(request.media_file_id)
# Resolve media payload: prefer media_items, fall back to legacy single-media fields
items_payload = None
primary_type = request.media_type
primary_file_id = request.media_file_id
primary_caption = request.media_caption
if request.media_items:
items_payload = [it.model_dump() for it in request.media_items]
first = request.media_items[0]
primary_type = first.type
primary_file_id = first.file_id
primary_caption = primary_caption or first.caption
has_media = bool(primary_file_id)
message = TicketMessage(
ticket_id=ticket.id,
user_id=ticket.user_id,
message_text=request.message,
is_from_admin=True,
has_media=has_media,
media_type=request.media_type if has_media else None,
media_file_id=request.media_file_id if has_media else None,
media_caption=request.media_caption if has_media else None,
media_type=primary_type if has_media else None,
media_file_id=primary_file_id if has_media else None,
media_caption=primary_caption if has_media else None,
media_items=items_payload,
created_at=datetime.now(UTC),
)
db.add(message)
+215 -20
View File
@@ -1,5 +1,6 @@
"""Admin routes for managing users in cabinet."""
import math
from datetime import UTC, datetime, timedelta
import structlog
@@ -70,6 +71,7 @@ from ..schemas.users import (
ResetTrialRequest,
ResetTrialResponse,
SortByEnum,
SubscriptionListItem,
SyncFromPanelRequest,
SyncFromPanelResponse,
SyncToPanelRequest,
@@ -119,6 +121,12 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt
subscription_is_trial = False
subscription_end_date = None
has_subscription = False
tariff_id = None
tariff_name = None
traffic_used_gb = 0.0
traffic_limit_gb = 0
device_limit = 0
days_remaining = 0
subs = getattr(user, 'subscriptions', None) or []
subscription = next((s for s in subs if s.is_active), subs[0] if subs else None)
@@ -127,6 +135,37 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt
subscription_status = subscription.status
subscription_is_trial = subscription.is_trial
subscription_end_date = subscription.end_date
tariff_id = subscription.tariff_id
tariff_name = subscription.tariff.name if subscription.tariff else None
traffic_used_gb = subscription.traffic_used_gb or 0.0
traffic_limit_gb = subscription.traffic_limit_gb or 0
device_limit = subscription.device_limit or 0
if subscription.end_date:
delta = subscription.end_date - datetime.now(UTC)
days_remaining = max(0, delta.days)
# Build per-subscription list (always — bulk actions need it for any mode)
sub_list: list[SubscriptionListItem] = []
if subs:
for s in subs:
s_days = 0
if s.end_date:
s_delta = s.end_date - datetime.now(UTC)
s_days = max(0, s_delta.days)
sub_list.append(
SubscriptionListItem(
id=s.id,
tariff_id=s.tariff_id,
tariff_name=s.tariff.name if s.tariff else None,
status=s.status,
is_trial=bool(s.is_trial),
end_date=s.end_date,
days_remaining=s_days,
traffic_used_gb=s.traffic_used_gb or 0.0,
traffic_limit_gb=s.traffic_limit_gb or 0,
device_limit=s.device_limit or 0,
)
)
return UserListItem(
id=user.id,
@@ -144,6 +183,13 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt
subscription_status=subscription_status,
subscription_is_trial=subscription_is_trial,
subscription_end_date=subscription_end_date,
tariff_id=tariff_id,
tariff_name=tariff_name,
traffic_used_gb=traffic_used_gb,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
days_remaining=days_remaining,
subscriptions=sub_list,
promo_group_id=user.promo_group_id,
promo_group_name=user.promo_group.name if user.promo_group else None,
total_spent_kopeks=user_stats.get('total_spent', 0),
@@ -347,7 +393,10 @@ async def _sync_subscription_to_panel(
changes['action'] = 'updated'
logger.info('Updated user in Remnawave panel', user_id=user.id)
except Exception as update_error:
if hasattr(update_error, 'status_code') and update_error.status_code == 404:
error_code = (getattr(update_error, 'response_data', None) or {}).get('errorCode', '')
if (
hasattr(update_error, 'status_code') and update_error.status_code == 404
) or error_code == 'A018':
panel_uuid = None # Will create new
else:
raise
@@ -417,6 +466,11 @@ async def list_users(
search: str | None = Query(None, max_length=255),
email: str | None = Query(None, max_length=255),
status: UserStatusEnum | None = Query(None),
subscription_status: str | None = Query(None, max_length=20),
tariff_id: str | None = Query(None, max_length=255),
promo_group_id: int | None = Query(None),
campaign_id: int | None = Query(None),
partner_id: int | None = Query(None),
sort_by: SortByEnum = Query(SortByEnum.CREATED_AT),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
@@ -443,6 +497,14 @@ async def list_users(
order_by_total_spent = sort_by == SortByEnum.TOTAL_SPENT
order_by_purchase_count = sort_by == SortByEnum.PURCHASE_COUNT
# Parse comma-separated tariff_ids
tariff_ids: list[int] | None = None
if tariff_id:
try:
tariff_ids = [int(x.strip()) for x in tariff_id.split(',') if x.strip()]
except ValueError:
tariff_ids = None
users = await get_users_list(
db=db,
offset=offset,
@@ -450,6 +512,11 @@ async def list_users(
search=search,
email=email,
status=user_status,
subscription_status=subscription_status,
tariff_ids=tariff_ids,
promo_group_id=promo_group_id,
campaign_id=campaign_id,
partner_id=partner_id,
order_by_balance=order_by_balance,
order_by_traffic=order_by_traffic,
order_by_last_activity=order_by_last_activity,
@@ -457,7 +524,17 @@ async def list_users(
order_by_purchase_count=order_by_purchase_count,
)
total = await get_users_count(db=db, status=user_status, search=search, email=email)
total = await get_users_count(
db=db,
status=user_status,
search=search,
email=email,
subscription_status=subscription_status,
tariff_ids=tariff_ids,
promo_group_id=promo_group_id,
campaign_id=campaign_id,
partner_id=partner_id,
)
# Get spending stats for all users
user_ids = [u.id for u in users]
@@ -823,6 +900,50 @@ async def get_user_panel_info(
return UserPanelInfoResponse(found=False)
@router.get('/{user_id}/subscription-request-history')
async def get_subscription_request_history(
user_id: int,
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
):
"""Get subscription request history from RemnaWave panel."""
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
panel_uuid = None
if settings.is_multi_tariff_enabled() and subscription_id:
from app.database.crud.subscription import get_subscription_by_id_for_user
sub = await get_subscription_by_id_for_user(db, subscription_id, user_id)
if sub:
panel_uuid = sub.remnawave_uuid
else:
panel_uuid = getattr(user, 'remnawave_uuid', None)
if not panel_uuid:
return {'total': 0, 'records': []}
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
if not service.is_configured:
return {'total': 0, 'records': []}
async with service.get_api_client() as api:
result = await api.get_subscription_request_history(panel_uuid, offset=offset, limit=limit)
return result
except Exception as e:
logger.error('Error getting subscription request history', user_id=user_id, error=e)
return {'total': 0, 'records': []}
@router.get('/{user_id}/node-usage', response_model=UserNodeUsageResponse)
async def get_user_node_usage(
user_id: int,
@@ -1050,6 +1171,17 @@ async def update_user_subscription(
detail='User already has a subscription. Enable multi-tariff mode to add more.',
)
# Проверка: нельзя создать вторую активную подписку с тем же тарифом
if is_multi_tariff and request.tariff_id:
from app.database.crud.subscription import get_subscription_by_user_and_tariff
existing = await get_subscription_by_user_and_tariff(db, user.id, request.tariff_id)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='User already has an active subscription for this tariff. Extend it instead.',
)
from app.database.crud.subscription import create_paid_subscription
days = request.days or 30
@@ -1069,16 +1201,25 @@ async def update_user_subscription(
if tariff.allowed_squads:
connected_squads = tariff.allowed_squads
new_sub = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=days,
traffic_limit_gb=traffic_limit,
device_limit=device_limit,
is_trial=is_trial,
tariff_id=request.tariff_id,
connected_squads=connected_squads,
)
from sqlalchemy.exc import IntegrityError
try:
new_sub = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=days,
traffic_limit_gb=traffic_limit,
device_limit=device_limit,
is_trial=is_trial,
tariff_id=request.tariff_id,
connected_squads=connected_squads,
)
except IntegrityError:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='User already has an active subscription for this tariff. Extend it instead.',
)
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, new_sub)
@@ -1191,6 +1332,18 @@ async def update_user_subscription(
detail='Tariff not found',
)
# Проверка: нельзя сменить тариф, если у пользователя уже есть
# другая активная подписка с целевым тарифом
if is_multi_tariff and request.tariff_id != subscription.tariff_id:
from app.database.crud.subscription import get_subscription_by_user_and_tariff
existing = await get_subscription_by_user_and_tariff(db, user.id, request.tariff_id)
if existing and existing.id != subscription.id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='User already has an active subscription for the target tariff',
)
# Preserve extra purchased devices above the old tariff's base limit
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
@@ -1322,6 +1475,18 @@ async def update_user_subscription(
)
if request.action == 'activate':
# Проверка: нельзя активировать, если у пользователя уже есть
# другая активная подписка с тем же тарифом
if is_multi_tariff and subscription.tariff_id:
from app.database.crud.subscription import get_subscription_by_user_and_tariff
existing = await get_subscription_by_user_and_tariff(db, user.id, subscription.tariff_id)
if existing and existing.id != subscription.id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Cannot activate: user already has an active subscription for this tariff',
)
subscription.status = SubscriptionStatus.ACTIVE.value
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
# Extend by 30 days if expired
@@ -1627,9 +1792,25 @@ async def block_user(
admin: User = Depends(require_permission('users:block')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Block a user (shortcut for status update)."""
request = UpdateUserStatusRequest(status=UserStatusEnum.BLOCKED, reason=reason)
return await update_user_status(user_id, request, admin, db)
"""Block a user — sets DB status AND disables panel user in RemnaWave."""
from app.services.user_service import UserService
user_service = UserService()
success = await user_service.block_user(
db,
user_id,
admin.id,
reason=reason or 'Заблокирован администратором',
)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found or block failed')
return UpdateUserStatusResponse(
success=True,
old_status='active',
new_status='blocked',
message='User blocked',
)
@router.post('/{user_id}/unblock', response_model=UpdateUserStatusResponse)
@@ -1638,9 +1819,20 @@ async def unblock_user(
admin: User = Depends(require_permission('users:block')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unblock a user (shortcut for status update)."""
request = UpdateUserStatusRequest(status=UserStatusEnum.ACTIVE)
return await update_user_status(user_id, request, admin, db)
"""Unblock a user — sets DB status AND re-enables panel user in RemnaWave."""
from app.services.user_service import UserService
user_service = UserService()
success = await user_service.unblock_user(db, user_id, admin.id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found or unblock failed')
return UpdateUserStatusResponse(
success=True,
old_status='blocked',
new_status='active',
message='User unblocked',
)
# === Restrictions Management ===
@@ -3043,7 +3235,7 @@ async def sync_user_from_panel(
int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 100
)
panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at)
days_remaining = max(1, (panel_expire_utc - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((panel_expire_utc - datetime.now(UTC)).total_seconds() / 86400))
new_sub = await create_paid_subscription(
db=db,
@@ -3246,7 +3438,10 @@ async def sync_user_to_panel(
await api.update_user(**update_kwargs)
action = 'updated'
except Exception as update_error:
if hasattr(update_error, 'status_code') and update_error.status_code == 404:
error_code = (getattr(update_error, 'response_data', None) or {}).get('errorCode', '')
if (
hasattr(update_error, 'status_code') and update_error.status_code == 404
) or error_code == 'A018':
# User not found in panel, create new
panel_uuid = None
else:
+267
View File
@@ -0,0 +1,267 @@
"""Apple In-App Purchase cabinet route."""
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.apple_iap import (
create_apple_transaction,
)
from app.database.crud.transaction import create_transaction as create_trans
from app.database.crud.user import lock_user_for_update
from app.database.models import PaymentMethod, TransactionType, User
from app.external.apple_iap import AppleIAPService
from app.utils.user_utils import format_referrer_info
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.apple_iap import ApplePurchaseRequest, ApplePurchaseResponse
logger = structlog.get_logger(__name__)
router = APIRouter(tags=['Cabinet Apple IAP'])
def get_apple_iap_service() -> AppleIAPService:
return AppleIAPService()
@router.post('/apple-purchase', response_model=ApplePurchaseResponse)
async def apple_purchase(
request: ApplePurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
apple_iap_service: AppleIAPService = Depends(get_apple_iap_service),
):
"""Verify an Apple In-App Purchase and credit the user's balance.
The iOS app calls this endpoint after a successful StoreKit transaction.
If the backend returns success=false, the iOS app will NOT finish the
transaction and will retry on next launch.
"""
if not settings.is_apple_iap_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Apple In-App Purchase is not enabled',
)
# Validate product ID
products = settings.get_apple_iap_products()
if request.product_id not in products:
logger.warning(
'Unknown Apple product ID',
product_id=request.product_id,
user_id=user.id,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Unknown product ID',
)
amount_kopeks = products[request.product_id]
# Verify transaction with Apple Server API (no DB lock needed).
# verify_transaction automatically falls back Sandbox<->Production.
txn_info = await apple_iap_service.verify_transaction(request.transaction_id, settings.APPLE_IAP_ENVIRONMENT)
if not txn_info:
logger.warning(
'Apple transaction verification failed',
transaction_id=request.transaction_id,
user_id=user.id,
)
return ApplePurchaseResponse(success=False)
# Validate transaction fields
validation_error = apple_iap_service.validate_transaction_info(txn_info, request.product_id)
if validation_error:
logger.warning(
'Apple transaction validation failed',
error=validation_error,
transaction_id=request.transaction_id,
user_id=user.id,
)
return ApplePurchaseResponse(success=False)
# FIX 4: appAccountToken is mandatory -- reject if missing
app_account_token = txn_info.get('appAccountToken')
if not app_account_token:
logger.warning(
'Apple appAccountToken missing -- rejecting transaction',
transaction_id=request.transaction_id,
user_id=user.id,
)
return ApplePurchaseResponse(success=False)
if app_account_token != str(user.id):
logger.warning(
'Apple appAccountToken mismatch -- possible replay',
expected=str(user.id),
received=app_account_token,
transaction_id=request.transaction_id,
user_id=user.id,
)
return ApplePurchaseResponse(success=False)
# Detect sandbox transactions -- store actual environment from Apple's response
actual_environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT)
is_sandbox = actual_environment == 'Sandbox'
if is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production':
# Sandbox transaction on a production server (e.g. App Review).
# Record it for audit but do NOT credit real balance.
logger.info(
'Apple sandbox transaction on production -- storing without balance credit',
transaction_id=request.transaction_id,
product_id=request.product_id,
user_id=user.id,
)
try:
async with db.begin_nested():
await create_apple_transaction(
db=db,
user_id=user.id,
transaction_id=request.transaction_id,
original_transaction_id=txn_info.get('originalTransactionId'),
product_id=request.product_id,
bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID),
amount_kopeks=amount_kopeks,
environment='Sandbox',
)
except IntegrityError:
pass # already stored
await db.commit()
return ApplePurchaseResponse(success=True)
# Atomically insert transaction record -- unique constraint on transaction_id
# prevents double-spend even under concurrent requests.
apple_txn = None
try:
async with db.begin_nested():
apple_txn = await create_apple_transaction(
db=db,
user_id=user.id,
transaction_id=request.transaction_id,
original_transaction_id=txn_info.get('originalTransactionId'),
product_id=request.product_id,
bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID),
amount_kopeks=amount_kopeks,
environment=actual_environment,
)
except IntegrityError:
logger.info(
'Apple transaction already processed (idempotent)',
transaction_id=request.transaction_id,
user_id=user.id,
)
return ApplePurchaseResponse(success=True)
# Create financial transaction record
transaction = await create_trans(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=f'Пополнение через Apple IAP: {request.product_id}',
payment_method=PaymentMethod.APPLE_IAP,
external_id=request.transaction_id,
is_completed=True,
commit=False,
)
# FIX 9: Link AppleTransaction to financial Transaction via FK
if apple_txn and transaction:
apple_txn.transaction_id_fk = transaction.id
apple_txn.updated_at = datetime.now(UTC)
# Lock user row and credit balance
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 += amount_kopeks
# FIX 10: Update user.updated_at when modifying balance
user.updated_at = datetime.now(UTC)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
topup_status = 'Первое пополнение' if was_first_topup else 'Пополнение'
await db.commit()
# --- Post-payment side-effects (after atomic commit) ---
from app.database.crud.transaction import emit_transaction_side_effects
try:
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=amount_kopeks,
user_id=user.id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.APPLE_IAP,
external_id=request.transaction_id,
)
except Exception as error:
logger.error('Ошибка emit_transaction_side_effects Apple IAP', error=error)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(db, user.id, amount_kopeks, bot=None)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения Apple IAP', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
# Admin notification + cart auto-purchase
try:
from app.bot_factory import create_bot
bot = create_bot()
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления Apple IAP', error=error)
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, amount_kopeks, db, bot)
except Exception as error:
logger.error('Ошибка при работе с сохраненной корзиной Apple IAP', user_id=user.id, error=error)
finally:
await bot.session.close()
except Exception as error:
logger.error('Ошибка создания бота для уведомлений Apple IAP', error=error)
logger.info(
'Apple IAP purchase credited',
transaction_id=request.transaction_id,
product_id=request.product_id,
amount_kopeks=amount_kopeks,
user_id=user.id,
)
return ApplePurchaseResponse(success=True)
+105 -64
View File
@@ -172,74 +172,112 @@ async def _process_campaign_bonus(
db: AsyncSession,
user: User,
campaign_slug: str | None,
telegram_id: int | None = None,
) -> CampaignBonusInfo | None:
"""Process campaign bonus for user during auth. Never raises."""
"""Process campaign bonus for user during auth. Never raises.
If ``campaign_slug`` is not provided but ``telegram_id`` is given, the
function falls back to Redis ``pending_campaign:{telegram_id}`` -- populated
by the bot's /start handler when a user opens an advertising campaign link
but then completes registration via the cabinet WebApp (Telegram menu
button) instead of the bot dialog. The Redis entry is cleared after a
successful consumption attempt.
"""
pending_campaign_consumed = False
if not campaign_slug and telegram_id:
try:
from app.services.referral_service import get_pending_campaign
pending = await get_pending_campaign(telegram_id)
if pending and pending.get('campaign_slug'):
campaign_slug = pending['campaign_slug']
pending_campaign_consumed = True
logger.info(
'Resolved campaign from Redis pending_campaign (cabinet)',
telegram_id=telegram_id,
campaign_slug=campaign_slug,
)
except Exception as e:
logger.warning('Failed to check pending campaign', error=e)
if not campaign_slug:
return None
try:
campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True)
if not campaign:
return None
try:
campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True)
if not campaign:
return None
# Skip if user IS the campaign partner — prevent self-referral
if campaign.partner_user_id and campaign.partner_user_id == user.id:
logger.debug(
'Skipping campaign attribution: user is the campaign partner',
user_id=user.id,
campaign_id=campaign.id,
)
return None
# Lock user row to prevent concurrent bonus application (race condition)
await db.execute(select(User).where(User.id == user.id).with_for_update())
existing = await get_campaign_registration_by_user(db, user.id)
if existing:
logger.debug('User already has campaign registration', user_id=user.id)
return None
# Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан)
if campaign.partner_user_id and not user.referred_by_id:
user.referred_by_id = campaign.partner_user_id
await db.flush()
try:
from app.bot_factory import create_bot
async with create_bot() as bot:
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
logger.info(
'Referral set from campaign partner',
# Skip if user IS the campaign partner — prevent self-referral
if campaign.partner_user_id and campaign.partner_user_id == user.id:
logger.debug(
'Skipping campaign attribution: user is the campaign partner',
user_id=user.id,
partner_user_id=campaign.partner_user_id,
campaign_id=campaign.id,
)
except Exception as e:
logger.error('Failed to process referral from campaign partner', error=e)
return None
service = AdvertisingCampaignService()
result = await service.apply_campaign_bonus(db, user, campaign)
if not result.success:
return None
# Lock user row to prevent concurrent bonus application (race condition)
await db.execute(select(User).where(User.id == user.id).with_for_update())
# Refresh user to get updated balance after bonus
await db.refresh(user)
existing = await get_campaign_registration_by_user(db, user.id)
if existing:
logger.debug('User already has campaign registration', user_id=user.id)
return None
return CampaignBonusInfo(
campaign_name=campaign.name,
bonus_type=result.bonus_type or campaign.bonus_type,
balance_kopeks=result.balance_kopeks,
subscription_days=result.subscription_days,
tariff_name=result.tariff_name,
)
except Exception:
logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug)
try:
await db.rollback()
# Re-fetch user so session stays usable for the caller
# Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан)
if campaign.partner_user_id and not user.referred_by_id:
user.referred_by_id = campaign.partner_user_id
await db.flush()
try:
from app.bot_factory import create_bot
async with create_bot() as bot:
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
logger.info(
'Referral set from campaign partner',
user_id=user.id,
partner_user_id=campaign.partner_user_id,
campaign_id=campaign.id,
)
except Exception as e:
logger.error('Failed to process referral from campaign partner', error=e)
service = AdvertisingCampaignService()
result = await service.apply_campaign_bonus(db, user, campaign)
if not result.success:
return None
# Refresh user to get updated balance after bonus
await db.refresh(user)
return CampaignBonusInfo(
campaign_name=campaign.name,
bonus_type=result.bonus_type or campaign.bonus_type,
balance_kopeks=result.balance_kopeks,
subscription_days=result.subscription_days,
tariff_name=result.tariff_name,
)
except Exception:
logger.exception('Failed to rollback after campaign bonus error', user_id=user.id)
return None
logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug)
try:
await db.rollback()
# Re-fetch user so session stays usable for the caller
await db.refresh(user)
except Exception:
logger.exception('Failed to rollback after campaign bonus error', user_id=user.id)
return None
finally:
# Clear Redis pending_campaign whenever we consumed it. Done regardless
# of success — if processing failed (already applied, race, exception),
# we don't want to keep retrying on every subsequent login.
if pending_campaign_consumed and telegram_id:
try:
from app.services.referral_service import clear_pending_campaign
await clear_pending_campaign(telegram_id)
except Exception:
pass
async def _process_referral_code(
@@ -584,8 +622,11 @@ async def auth_telegram(
except Exception:
pass
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
# Process campaign bonus.
# Pass telegram_id so the function can fall back to Redis pending_campaign
# if the user came via /start <campaign> in the bot but completed
# registration in the WebApp without an explicit campaign_slug.
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=telegram_id)
if response.campaign_bonus:
response.user = _user_to_response(user)
@@ -691,8 +732,8 @@ async def auth_telegram_widget(
except Exception:
pass
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
# Process campaign bonus (pending_campaign Redis fallback for Telegram Login Widget)
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=request.id)
if response.campaign_bonus:
response.user = _user_to_response(user)
@@ -815,10 +856,9 @@ async def auth_telegram_oidc(
# Update user info from OIDC claims
if username and username != user.username:
user.username = username
if first_name and first_name != user.first_name:
user.first_name = first_name
if last_name is not None and last_name != user.last_name:
user.last_name = last_name
# NOTE: не обновляем first_name/last_name из OIDC
# Telegram OIDC возвращает только поле name как полное имя без разделения на first/last
# Имя правильно заполняется через middleware при обычном использовании бота
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
@@ -838,7 +878,8 @@ async def auth_telegram_oidc(
except Exception:
pass
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
# Process campaign bonus (pending_campaign Redis fallback for Telegram OIDC)
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=telegram_id)
if response.campaign_bonus:
response.user = _user_to_response(user)
+202
View File
@@ -854,6 +854,159 @@ async def create_topup(
detail='Failed to create RollyPay payment',
)
elif request.payment_method == 'overpay':
if not settings.is_overpay_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Overpay payment method is unavailable',
)
payment_service = PaymentService()
result = await payment_service.create_overpay_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create Overpay payment',
)
elif request.payment_method == 'aurapay':
if not settings.is_aurapay_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='AuraPay payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_aurapay_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_type=payment_method_type,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create AuraPay payment',
)
elif request.payment_method == 'jupiter':
if not settings.is_jupiter_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Jupiter payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_jupiter_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_type=payment_method_type,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create Jupiter payment',
)
elif request.payment_method == 'donut':
if not settings.is_donut_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Donut payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_donut_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_type=payment_method_type,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create Donut payment',
)
elif request.payment_method == 'lava':
if not settings.is_lava_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Lava payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_lava_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_type=payment_method_type,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create Lava payment',
)
else:
# For other payment methods, redirect to bot
raise HTTPException(
@@ -1005,6 +1158,45 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.JUPITER:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'processing': ('', 'Обрабатывается'),
'success': ('', 'Оплачено'),
'cancelled': ('', 'Отменено'),
'declined': ('', 'Отклонено'),
'error': ('', 'Ошибка'),
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.DONUT:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'created': ('', 'Создано'),
'processing': ('', 'Обрабатывается'),
'success': ('', 'Оплачено'),
'cancelled': ('', 'Отменено'),
'error': ('', 'Ошибка'),
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.LAVA:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'created': ('', 'Создано'),
'processing': ('', 'Обрабатывается'),
'success': ('', 'Оплачено'),
'cancel': ('', 'Отменено'),
'cancelled': ('', 'Отменено'),
'expired': ('', 'Истёк'),
'failed': ('', 'Ошибка'),
'error': ('', 'Ошибка'),
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
}
return mapping.get(status, ('', 'Неизвестно'))
return '', 'Неизвестно'
@@ -1146,15 +1338,20 @@ async def get_latest_payment_by_method(
from sqlalchemy.orm import selectinload
from app.database.models import (
AuraPayPayment,
CloudPaymentsPayment,
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
OverpayPayment,
Pal24Payment,
PayPearPayment,
PlategaPayment,
RioPayPayment,
RollyPayPayment,
SeverPayPayment,
WataPayment,
YooKassaPayment,
)
@@ -1171,6 +1368,11 @@ async def get_latest_payment_by_method(
PaymentMethod.FREEKASSA: FreekassaPayment,
PaymentMethod.KASSA_AI: KassaAiPayment,
PaymentMethod.RIOPAY: RioPayPayment,
PaymentMethod.SEVERPAY: SeverPayPayment,
PaymentMethod.ROLLYPAY: RollyPayPayment,
PaymentMethod.PAYPEAR: PayPearPayment,
PaymentMethod.OVERPAY: OverpayPayment,
PaymentMethod.AURAPAY: AuraPayPayment,
}
model = model_map.get(payment_method)
+73 -1
View File
@@ -17,7 +17,7 @@ from app.config import settings
from app.database.crud.system_setting import get_setting_value
from app.database.models import SystemSetting, User
from ..dependencies import get_cabinet_db, require_permission
from ..dependencies import get_cabinet_db, get_current_cabinet_user, require_permission
logger = structlog.get_logger(__name__)
@@ -291,12 +291,24 @@ class GiftEnabledUpdate(BaseModel):
enabled: bool
class OfflineConvGoal(BaseModel):
"""Yandex Metrika offline conversion goal descriptor."""
name: str
event_id: str
dedup: str
class AnalyticsCountersResponse(BaseModel):
"""Analytics counter settings."""
yandex_metrika_id: str = ''
google_ads_id: str = ''
google_ads_label: str = ''
offline_conv_enabled: bool = False
offline_conv_counter_id: str = ''
offline_conv_measurement_secret_masked: str = ''
offline_conv_goals: list[OfflineConvGoal] = []
class AnalyticsCountersUpdate(BaseModel):
@@ -924,10 +936,27 @@ async def get_analytics_counters(
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
# Yandex Metrika offline conversions snapshot from Settings
oc_enabled = bool(getattr(settings, 'YANDEX_OFFLINE_CONV_ENABLED', False))
oc_counter = str(getattr(settings, 'YANDEX_OFFLINE_CONV_COUNTER_ID', '') or '')
oc_secret = str(getattr(settings, 'YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET', '') or '')
oc_secret_masked = ('*' * 8 + oc_secret[-4:]) if len(oc_secret) > 4 else ('***' if oc_secret else '')
oc_goals: list[OfflineConvGoal] = []
if oc_enabled:
oc_goals = [
OfflineConvGoal(name='Registration', event_id='registration', dedup='user_id'),
OfflineConvGoal(name='Trial', event_id='trial-add', dedup='user_id'),
OfflineConvGoal(name='Purchase', event_id='purchase', dedup='order_id'),
]
return AnalyticsCountersResponse(
yandex_metrika_id=yandex_id,
google_ads_id=google_id,
google_ads_label=google_label,
offline_conv_enabled=oc_enabled,
offline_conv_counter_id=oc_counter,
offline_conv_measurement_secret_masked=oc_secret_masked,
offline_conv_goals=oc_goals,
)
@@ -966,13 +995,56 @@ async def update_analytics_counters(
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
oc_enabled = bool(getattr(settings, 'YANDEX_OFFLINE_CONV_ENABLED', False))
oc_counter = str(getattr(settings, 'YANDEX_OFFLINE_CONV_COUNTER_ID', '') or '')
oc_secret = str(getattr(settings, 'YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET', '') or '')
oc_secret_masked = ('*' * 8 + oc_secret[-4:]) if len(oc_secret) > 4 else ('***' if oc_secret else '')
oc_goals: list[OfflineConvGoal] = []
if oc_enabled:
oc_goals = [
OfflineConvGoal(name='Registration', event_id='registration', dedup='user_id'),
OfflineConvGoal(name='Trial', event_id='trial-add', dedup='user_id'),
OfflineConvGoal(name='Purchase', event_id='purchase', dedup='order_id'),
]
return AnalyticsCountersResponse(
yandex_metrika_id=yandex_id,
google_ads_id=google_id,
google_ads_label=google_label,
offline_conv_enabled=oc_enabled,
offline_conv_counter_id=oc_counter,
offline_conv_measurement_secret_masked=oc_secret_masked,
offline_conv_goals=oc_goals,
)
# ============ Yandex CID Sync ============
class YandexCidRequest(BaseModel):
cid: str = Field(max_length=128, pattern=r'^[A-Za-z0-9._:-]{4,128}$')
@router.post('/analytics/yandex-cid', status_code=204)
async def store_yandex_cid(
body: YandexCidRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Store Yandex Metrika ClientID for the authenticated cabinet user."""
try:
from app.services import yandex_offline_conv_service as yandex_conv
await yandex_conv.store_cid(db, user.id, body.cid, source='cabinet')
await db.commit()
except Exception as exc:
logger.warning('Failed to store yandex_cid', user_id=user.id, exc=str(exc))
try:
await db.rollback()
except Exception:
pass
# ============ Lite Mode Routes ============
+68
View File
@@ -0,0 +1,68 @@
"""Public info page routes for cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.info_pages import get_all_info_pages, get_info_page_by_slug, get_tab_replacements
from ..dependencies import get_cabinet_db
from ..schemas.info_pages import InfoPageListItem, InfoPageResponse
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/info-pages', tags=['Cabinet Info Pages'])
@router.get('', response_model=list[InfoPageListItem])
async def list_active_info_pages(
page_type: str | None = Query(None, pattern=r'^(page|faq)$'),
db: AsyncSession = Depends(get_cabinet_db),
) -> list[InfoPageListItem]:
"""Get all active info pages (public, no auth required)."""
try:
pages = await get_all_info_pages(db, include_inactive=False, page_type=page_type)
return [InfoPageListItem.model_validate(p) for p in pages]
except Exception:
logger.exception('Failed to list active info pages')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load info pages',
)
@router.get('/tab-replacements')
async def get_info_page_tab_replacements(
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, str | None]:
"""Get tab replacement mapping (public, no auth required).
Returns a dict mapping each replaceable tab to the info page slug that replaces it,
or null if no replacement is set: ``{faq: slug_or_null, ...}``.
"""
try:
return await get_tab_replacements(db)
except Exception:
logger.exception('Failed to get tab replacements')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load tab replacements',
)
@router.get('/{slug}', response_model=InfoPageResponse)
async def get_info_page_by_slug_public(
slug: str = Path(..., max_length=200, pattern=r'^[a-z0-9\-]+$'),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Get a single info page by slug (public, no auth required)."""
page = await get_info_page_by_slug(db, slug)
if not page or not page.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
return InfoPageResponse.model_validate(page)
+36 -1
View File
@@ -23,7 +23,7 @@ from app.services.guest_purchase_service import (
)
from app.services.payment_method_config_service import _get_method_defaults
from app.services.payment_service import PaymentService
from app.utils.cache import RateLimitCache
from app.utils.cache import RateLimitCache, cache
logger = structlog.get_logger(__name__)
@@ -99,6 +99,11 @@ class LandingConfigResponse(BaseModel):
meta_description: str | None = None
discount: LandingDiscountInfo | None = None # null if no active discount
background_config: dict | None = None
sticky_pay_button: bool = False
analytics_view_enabled: bool = False
analytics_view_goal: str | None = None
analytics_click_enabled: bool = False
analytics_click_goal: str | None = None
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
@@ -123,6 +128,9 @@ class PurchaseRequest(BaseModel):
gift_recipient_type: str | None = Field(default=None, pattern=r'^(email|telegram)$')
gift_recipient_value: str | None = Field(default=None, max_length=255)
gift_message: str | None = Field(default=None, max_length=1000)
yandex_cid: str | None = Field(default=None, max_length=128, pattern=r'^[A-Za-z0-9._:-]{4,128}$')
referrer: str | None = Field(default=None, max_length=500)
subid: str | None = Field(default=None, max_length=255)
@model_validator(mode='after')
def validate_contacts(self) -> 'PurchaseRequest':
@@ -535,6 +543,11 @@ async def get_landing_config(
meta_description=resolve_locale_text(landing.meta_description, lang) or None,
discount=discount,
background_config=landing.background_config,
sticky_pay_button=landing.sticky_pay_button,
analytics_view_enabled=landing.analytics_view_enabled,
analytics_view_goal=landing.analytics_view_goal,
analytics_click_enabled=landing.analytics_click_enabled,
analytics_click_goal=landing.analytics_click_goal,
)
@@ -644,9 +657,17 @@ async def create_landing_purchase(
gift_recipient_type=body.gift_recipient_type,
gift_recipient_value=body.gift_recipient_value,
gift_message=body.gift_message,
subid=body.subid,
referrer=body.referrer,
commit=False,
)
# Fallback to HTTP Referer header if body did not supply one
if not purchase.referrer:
http_referrer = raw_request.headers.get('referer') or raw_request.headers.get('referrer')
if http_referrer and len(http_referrer) <= 500:
purchase.referrer = http_referrer
# Determine return URL: per-method override → default cabinet URL
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
default_return_url = f'{cabinet_base}/buy/success/{purchase.token}'
@@ -690,6 +711,20 @@ async def create_landing_purchase(
await db.commit()
await db.refresh(purchase)
# Persist Yandex CID in cache so fulfill_purchase can link it to the user later
if body.yandex_cid and settings.YANDEX_OFFLINE_CONV_ENABLED:
try:
await cache.set(f'yacid:purchase:{purchase.token}', body.yandex_cid, expire=86400)
except Exception:
pass
# Persist subid in cache for S2S postback
if body.subid:
try:
await cache.set(f'subid:purchase:{purchase.token}', body.subid, expire=86400)
except Exception:
pass
return PurchaseResponse(
purchase_token=purchase.token,
payment_url=payment_url,
+10
View File
@@ -99,25 +99,35 @@ async def upload_media(
bot = create_bot()
try:
# Send with disable_notification to avoid pinging admins — this is just staging
if media_type_normalized == 'photo':
message = await bot.send_photo(
chat_id=target_chat_id,
photo=upload,
disable_notification=True,
)
media = message.photo[-1]
elif media_type_normalized == 'video':
message = await bot.send_video(
chat_id=target_chat_id,
video=upload,
disable_notification=True,
)
media = message.video
else:
message = await bot.send_document(
chat_id=target_chat_id,
document=upload,
disable_notification=True,
)
media = message.document
# Delete the staging message immediately — file_id persists after deletion
try:
await bot.delete_message(chat_id=target_chat_id, message_id=message.message_id)
except Exception:
pass # Best-effort cleanup — file_id is already captured
media_url = _build_media_url(request, media.file_id)
logger.info(
+1 -1
View File
@@ -144,7 +144,7 @@ async def get_available_polls(
selectinload(PollResponse.poll).selectinload(Poll.questions),
selectinload(PollResponse.answers),
)
.order_by(PollResponse.created_at.desc())
.order_by(PollResponse.sent_at.desc())
)
responses = result.scalars().all()
+24
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.promocode_service import PromoCodeService
@@ -67,6 +68,29 @@ async def activate_promocode(
balance_before_rubles = result.get('balance_before_kopeks', 0) / 100
balance_after_rubles = result.get('balance_after_kopeks', 0) / 100
# Send admin notification (same as bot handler)
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_promocode_activation_notification(
db,
user,
result.get('promocode', {'code': request.code.strip()}),
result.get('description', ''),
result.get('balance_before_kopeks'),
result.get('balance_after_kopeks'),
)
finally:
await bot.session.close()
except Exception:
pass
return PromocodeActivateResponse(
success=True,
message='Promo code activated successfully',
+2
View File
@@ -19,6 +19,7 @@ from .subscription_modules import (
devices_router,
purchase_router,
renewal_router,
revoke_router,
servers_router,
status_router,
tariff_switch_router,
@@ -50,3 +51,4 @@ router.include_router(servers_router)
router.include_router(autopay_router)
router.include_router(daily_router)
router.include_router(tariff_switch_router)
router.include_router(revoke_router)
@@ -10,6 +10,7 @@ from .devices import router as devices_router
from .multi_tariff import router as multi_tariff_router
from .purchase import router as purchase_router
from .renewal import router as renewal_router
from .revoke import router as revoke_router
from .servers import router as servers_router
from .status import router as status_router
from .tariff_switch import router as tariff_switch_router
@@ -23,6 +24,7 @@ __all__ = [
'multi_tariff_router',
'purchase_router',
'renewal_router',
'revoke_router',
'servers_router',
'status_router',
'tariff_switch_router',
@@ -49,7 +49,8 @@ async def update_autopay(
)
# Триальные подписки — пробник, автопродление не имеет смысла
if subscription.is_trial:
# NULL-safe: is_trial can be None in legacy rows — treat as trial
if subscription.is_trial is not False:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Autopay is not available for trial subscriptions',
@@ -13,6 +13,7 @@ POST /subscription/devices/save-cart
from __future__ import annotations
import math
from datetime import UTC, datetime
from typing import Any
@@ -108,7 +109,24 @@ async def purchase_devices_legacy(
detail='Докупка устройств недоступна',
)
base_total_price = device_price * request.devices
# Устройства в пределах тарифного лимита — бесплатные
current_devices = subscription.device_limit or 1
if tariff:
tariff_included = tariff.device_limit or 0
if current_devices < tariff_included:
free_devices = tariff_included - current_devices
chargeable_devices = max(0, request.devices - free_devices)
else:
chargeable_devices = request.devices
else:
free_baseline = settings.DEFAULT_DEVICE_LIMIT
if current_devices < free_baseline:
free_devices = free_baseline - current_devices
chargeable_devices = max(0, request.devices - free_devices)
else:
chargeable_devices = request.devices
base_total_price = device_price * chargeable_devices
# Lock user row to prevent TOCTOU on promo-offer state
from app.database.crud.user import lock_user_for_pricing
@@ -363,13 +381,30 @@ async def purchase_devices(
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
days_left = max(1, (end_date - now).days)
days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400))
total_days = 30 # Base period for device price calculation
# Устройства в пределах тарифного лимита — бесплатные
if tariff:
tariff_included = tariff.device_limit or 0
if current_devices < tariff_included:
free_devices = tariff_included - current_devices
chargeable_devices = max(0, request.devices - free_devices)
else:
chargeable_devices = request.devices
else:
free_baseline = settings.DEFAULT_DEVICE_LIMIT
if current_devices < free_baseline:
free_devices = free_baseline - current_devices
chargeable_devices = max(0, request.devices - free_devices)
else:
chargeable_devices = request.devices
# Calculate base price before discount
base_price_per_month = device_price * request.devices
base_price_per_month = device_price * chargeable_devices
base_price_prorated = int(base_price_per_month * days_left / total_days)
base_price_prorated = max(100, base_price_prorated) # Minimum 1 ruble
if chargeable_devices > 0:
base_price_prorated = max(100, base_price_prorated) # Minimum 1 ruble
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
from app.database.crud.user import lock_user_for_pricing
@@ -624,11 +659,28 @@ async def save_devices_cart(
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
days_left = max(1, (end_date - now).days)
days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400))
total_days = 30
base_total_price = int(device_price * request.devices * days_left / total_days)
base_total_price = max(100, base_total_price) # Minimum 1 ruble
# Устройства в пределах тарифного лимита — бесплатные
if tariff:
tariff_included = tariff.device_limit or 0
if current_devices < tariff_included:
free_devices = tariff_included - current_devices
chargeable_devices = max(0, request.devices - free_devices)
else:
chargeable_devices = request.devices
else:
free_baseline = settings.DEFAULT_DEVICE_LIMIT
if current_devices < free_baseline:
free_devices = free_baseline - current_devices
chargeable_devices = max(0, request.devices - free_devices)
else:
chargeable_devices = request.devices
base_total_price = int(device_price * chargeable_devices * days_left / total_days)
if chargeable_devices > 0:
base_total_price = max(100, base_total_price) # Minimum 1 ruble
# Apply discount from promo group
period_hint_days = days_left
@@ -721,12 +773,29 @@ async def get_device_price(
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
days_left = max(1, (end_date - now).days)
days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400))
total_days = 30
# Устройства в пределах тарифного лимита — бесплатные
if tariff:
tariff_included = tariff.device_limit or 0
if current_devices < tariff_included:
free_devices = tariff_included - current_devices
chargeable_devices = max(0, devices - free_devices)
else:
chargeable_devices = devices
else:
free_baseline = settings.DEFAULT_DEVICE_LIMIT
if current_devices < free_baseline:
free_devices = free_baseline - current_devices
chargeable_devices = max(0, devices - free_devices)
else:
chargeable_devices = devices
# Calculate base price before discount (total first, then floor)
base_total_price = int(device_price * devices * days_left / total_days)
base_total_price = max(100, base_total_price)
base_total_price = int(device_price * chargeable_devices * days_left / total_days)
if chargeable_devices > 0:
base_total_price = max(100, base_total_price)
# Apply discount from promo group
period_hint_days = days_left
@@ -353,6 +353,9 @@ async def get_purchase_options(
'all_tariffs_purchased': len(purchased_tariff_ids) >= len(tariffs)
if settings.is_multi_tariff_enabled()
else False,
# Направления смены тарифа
'tariff_switch_upgrade_enabled': settings.TARIFF_SWITCH_UPGRADE_ENABLED,
'tariff_switch_downgrade_enabled': settings.TARIFF_SWITCH_DOWNGRADE_ENABLED,
}
# Classic mode - return periods
@@ -892,8 +895,14 @@ async def purchase_tariff(
except Exception as trial_err:
logger.warning('Failed to disable trial on RemnaWave', error=trial_err, trial_id=trial_sub.id)
try:
if subscription.remnawave_uuid:
# Existing subscription with Remnawave user — update it
# Mirror the bot handler logic: in single-tariff mode, check user.remnawave_uuid
# (webhook clears it on panel deletion), not subscription.remnawave_uuid
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(user, 'remnawave_uuid', None)
if not _should_create:
await service.update_remnawave_user(
db,
subscription,
@@ -902,7 +911,6 @@ async def purchase_tariff(
sync_squads=True,
)
else:
# New subscription — create new Remnawave user
await service.create_remnawave_user(
db,
subscription,
@@ -916,7 +924,7 @@ async def purchase_tariff(
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create' if not subscription.remnawave_uuid else 'update',
action='create' if _should_create else 'update',
)
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
@@ -1219,9 +1227,11 @@ async def activate_trial(
trial_tariff = None
if trial_tariff:
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
trial_traffic_limit = trial_tariff.traffic_limit_gb
trial_device_limit = trial_tariff.device_limit
trial_squads = trial_tariff.allowed_squads or []
trial_squads = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
tariff_id_for_trial = trial_tariff.id
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
@@ -1235,7 +1245,7 @@ async def activate_trial(
except Exception as e:
logger.error('Error getting trial tariff', error=e)
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
# No trial tariff configured, use the legacy random trial squad fallback.
if not trial_squads:
from app.database.crud.server_squad import get_random_trial_squad_uuid
@@ -57,7 +57,14 @@ async def get_renewal_options(
return []
# Determine available periods
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
# Скрытый/неактивный тариф (например, триальный после промокода) —
# не показываем его периоды, используем стандартные
if (
subscription.tariff_id
and subscription.tariff
and subscription.tariff.is_active
and subscription.tariff.period_prices
):
periods = sorted(int(k) for k in subscription.tariff.period_prices.keys())
else:
periods = settings.get_available_renewal_periods()
@@ -128,7 +135,12 @@ async def renew_subscription(
detail=f'Cannot renew subscription with status: {_actual_status}',
)
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
if (
subscription.tariff_id
and subscription.tariff
and subscription.tariff.is_active
and subscription.tariff.period_prices
):
available_periods = [int(p) for p in subscription.tariff.period_prices.keys()]
else:
available_periods = settings.get_available_renewal_periods()
@@ -0,0 +1,92 @@
"""Cabinet API endpoint for subscription reissue.
POST /subscription/revoke
"""
from __future__ import annotations
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.subscription_service import SubscriptionService
from ...dependencies import get_cabinet_db, get_current_cabinet_user
from .helpers import resolve_subscription
logger = structlog.get_logger(__name__)
router = APIRouter()
@router.post('/revoke')
async def revoke_subscription(
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict:
"""Revoke and reissue subscription (generate new connection link)."""
if not settings.is_subscription_revoke_enabled():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Subscription reissue is not available',
)
# Reload user from current session
from app.database.crud.user import get_user_by_id
fresh_user = await get_user_by_id(db, user.id)
if not fresh_user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
subscription = await resolve_subscription(db, fresh_user, subscription_id)
if not subscription:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Subscription not found')
if not subscription.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Only active subscriptions can be reissued',
)
# Check cooldown
if subscription.last_revoke_at:
elapsed = (datetime.now(UTC) - subscription.last_revoke_at).total_seconds()
cooldown = settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS
if elapsed < cooldown:
remaining = int(cooldown - elapsed)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f'Cooldown active. Try again in {remaining} seconds.',
headers={'Retry-After': str(remaining)},
)
# Execute revoke
sub_service = SubscriptionService()
new_url = await sub_service.revoke_subscription(db, subscription)
if not new_url:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to reissue subscription',
)
# Update cooldown timestamp
subscription.last_revoke_at = datetime.now(UTC)
await db.commit()
logger.info(
'Subscription revoked via cabinet API',
user_id=user.id,
subscription_id=subscription.id,
)
return {
'success': True,
'cooldown_seconds': settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS,
}
@@ -119,6 +119,18 @@ async def preview_tariff_switch(
)
upgrade_cost = switch_result.upgrade_cost
is_upgrade = switch_result.is_upgrade
# Проверяем разрешение на смену в данном направлении
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Повышение тарифа недоступно',
)
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Понижение тарифа недоступно',
)
base_upgrade_cost = switch_result.raw_cost
discount_value = switch_result.discount_value
period_discount_percent = switch_result.effective_discount_pct
@@ -263,11 +275,24 @@ async def switch_tariff(
user=user,
)
upgrade_cost = switch_result.upgrade_cost
is_upgrade = switch_result.is_upgrade
base_upgrade_cost = switch_result.raw_cost
discount_value = switch_result.discount_value
period_discount_percent = switch_result.effective_discount_pct
new_period_days = switch_result.new_period_days
# Проверяем разрешение на смену в данном направлении
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Повышение тарифа недоступно',
)
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Понижение тарифа недоступно',
)
# Validate daily price for switching TO daily
new_is_daily = getattr(new_tariff, 'is_daily', False)
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
@@ -9,6 +9,7 @@ POST /subscription/traffic/save-cart
from __future__ import annotations
import math
from datetime import UTC, datetime
from typing import Any
@@ -478,7 +479,7 @@ async def save_traffic_cart(
from app.utils.pricing_utils import calculate_prorated_price as _calc_prorated
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
prorated_price, _ = _calc_prorated(
base_price_kopeks,
subscription.end_date,
+49 -9
View File
@@ -20,6 +20,7 @@ from ..schemas.tickets import (
TicketCreateRequest,
TicketDetailResponse,
TicketListResponse,
TicketMediaItem,
TicketMessageCreateRequest,
TicketMessageResponse,
TicketResponse,
@@ -33,14 +34,23 @@ router = APIRouter(prefix='/tickets', tags=['Cabinet Tickets'])
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
"""Convert TicketMessage to response."""
raw_items = getattr(message, 'media_items', None) or None
items = None
if raw_items:
try:
items = [TicketMediaItem(**it) for it in raw_items]
except (TypeError, KeyError, ValueError) as exc:
logger.warning('Failed to parse media_items', message_id=message.id, error=str(exc))
items = None
return TicketMessageResponse(
id=message.id,
message_text=message.message_text or '',
is_from_admin=message.is_from_admin,
has_media=bool(message.media_file_id),
has_media=bool(message.media_file_id) or bool(items),
media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption,
media_items=items,
created_at=message.created_at,
)
@@ -143,15 +153,30 @@ async def create_ticket(
db.add(ticket)
await db.flush()
# Resolve media payload
items_payload = None
primary_type = request.media_type
primary_file_id = request.media_file_id
primary_caption = request.media_caption
if getattr(request, 'media_items', None):
items_payload = [it.model_dump() for it in request.media_items]
first = request.media_items[0]
primary_type = first.type
primary_file_id = first.file_id
primary_caption = primary_caption or first.caption
# Create initial message with optional media
has_media = bool(primary_file_id)
message = TicketMessage(
ticket_id=ticket.id,
user_id=user.id,
message_text=request.message,
is_from_admin=False,
media_type=request.media_type,
media_file_id=request.media_file_id,
media_caption=request.media_caption,
has_media=has_media,
media_type=primary_type if has_media else None,
media_file_id=primary_file_id if has_media else None,
media_caption=primary_caption if has_media else None,
media_items=items_payload,
created_at=datetime.now(UTC),
)
db.add(message)
@@ -259,15 +284,30 @@ async def add_ticket_message(
detail='Replies to this ticket are blocked',
)
# Resolve media payload
items_payload = None
primary_type = request.media_type
primary_file_id = request.media_file_id
primary_caption = request.media_caption
if getattr(request, 'media_items', None):
items_payload = [it.model_dump() for it in request.media_items]
first = request.media_items[0]
primary_type = first.type
primary_file_id = first.file_id
primary_caption = primary_caption or first.caption
# Create message with optional media
has_media = bool(primary_file_id)
message = TicketMessage(
ticket_id=ticket.id,
user_id=user.id,
message_text=request.message,
is_from_admin=False,
media_type=request.media_type,
media_file_id=request.media_file_id,
media_caption=request.media_caption,
has_media=has_media,
media_type=primary_type if has_media else None,
media_file_id=primary_file_id if has_media else None,
media_caption=primary_caption if has_media else None,
media_items=items_payload,
created_at=datetime.now(UTC),
)
db.add(message)
@@ -286,8 +326,8 @@ async def add_ticket_message(
ticket,
request.message,
db,
media_file_id=request.media_file_id,
media_type=request.media_type,
media_file_id=primary_file_id,
media_type=primary_type,
)
except Exception as e:
logger.error('Error notifying admins about ticket reply from cabinet', error=e)
+23
View File
@@ -0,0 +1,23 @@
"""Apple In-App Purchase schemas for cabinet."""
from pydantic import BaseModel, Field, field_validator
class ApplePurchaseRequest(BaseModel):
"""Request to verify and credit an Apple IAP transaction."""
product_id: str = Field(..., description='Apple product ID (e.g. com.bitnet.vpnclient.topup.100)')
transaction_id: str = Field(..., min_length=1, max_length=64, description='Apple StoreKit transaction ID')
@field_validator('transaction_id')
@classmethod
def transaction_id_must_be_numeric(cls, v: str) -> str:
if not v.isdigit():
raise ValueError('transaction_id must contain only digits')
return v
class ApplePurchaseResponse(BaseModel):
"""Response indicating whether the purchase was successfully credited."""
success: bool
+78
View File
@@ -0,0 +1,78 @@
"""Schemas for admin bulk actions."""
from enum import StrEnum
from pydantic import BaseModel, Field, model_validator
class BulkActionType(StrEnum):
EXTEND_SUBSCRIPTION = 'extend_subscription'
CANCEL_SUBSCRIPTION = 'cancel_subscription'
ACTIVATE_SUBSCRIPTION = 'activate_subscription'
CHANGE_TARIFF = 'change_tariff'
ADD_DAYS = 'add_days'
ADD_TRAFFIC = 'add_traffic'
ADD_BALANCE = 'add_balance'
ASSIGN_PROMO_GROUP = 'assign_promo_group'
GRANT_SUBSCRIPTION = 'grant_subscription'
SET_DEVICES = 'set_devices'
DELETE_SUBSCRIPTION = 'delete_subscription'
DELETE_USER = 'delete_user'
class BulkActionParams(BaseModel):
days: int | None = Field(None, ge=1, le=3650)
tariff_id: int | None = Field(None, gt=0)
traffic_gb: int | None = Field(None, ge=1, le=10000)
amount_kopeks: int | None = Field(None, ge=1, le=2_000_000_000)
balance_description: str = Field(default='Массовое начисление баланса', max_length=500)
promo_group_id: int | None = None
device_limit: int | None = Field(None, ge=1, le=50)
delete_from_panel: bool = Field(default=True)
force_delete_active_paid: bool = Field(default=False)
class BulkSubscriptionInfo(BaseModel):
id: int
tariff_id: int | None = None
tariff_name: str | None = None
status: str
days_remaining: int
traffic_used_gb: float = 0
traffic_limit_gb: int = 0
device_limit: int = 0
class BulkExecuteRequest(BaseModel):
action: BulkActionType
user_ids: list[int] | None = Field(None, min_length=1, max_length=500)
subscription_ids: list[int] | None = Field(None, min_length=1, max_length=2000)
params: BulkActionParams = Field(default_factory=BulkActionParams)
dry_run: bool = Field(default=False, description='Preview only, no mutations')
@model_validator(mode='after')
def _exactly_one_target(self):
has_users = self.user_ids is not None
has_subs = self.subscription_ids is not None
if has_users == has_subs:
raise ValueError('Exactly one of user_ids or subscription_ids must be provided')
return self
class BulkUserResult(BaseModel):
user_id: int
subscription_id: int | None = None
success: bool
message: str
username: str | None = None
subscriptions: list[BulkSubscriptionInfo] | None = None
class BulkExecuteResponse(BaseModel):
action: str
total: int
success_count: int
error_count: int
skipped_count: int
dry_run: bool
results: list[BulkUserResult]
+78
View File
@@ -0,0 +1,78 @@
"""Schemas for info pages in cabinet."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class InfoPageResponse(BaseModel):
"""Full info page response."""
id: int
slug: str
title: dict[str, str]
content: dict[str, str]
page_type: str = 'page'
is_active: bool
sort_order: int
icon: str | None = None
replaces_tab: str | None = None
created_at: datetime
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class InfoPageListItem(BaseModel):
"""Compact info page for list views."""
id: int
slug: str
title: dict[str, str]
page_type: str = 'page'
is_active: bool
sort_order: int
icon: str | None = None
replaces_tab: str | None = None
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class InfoPageCreateRequest(BaseModel):
"""Request to create an info page."""
slug: str = Field(min_length=1, max_length=200, pattern=r'^[a-z0-9\-]+$')
title: dict[str, str] = Field(default_factory=dict)
content: dict[str, str] = Field(default_factory=dict)
page_type: str = Field(default='page', pattern=r'^(page|faq)$')
is_active: bool = True
sort_order: int = 0
icon: str | None = Field(None, max_length=50)
replaces_tab: str | None = Field(None, pattern=r'^(faq|rules|privacy|offer)$')
class InfoPageUpdateRequest(BaseModel):
"""Request to update an info page."""
slug: str | None = Field(None, min_length=1, max_length=200, pattern=r'^[a-z0-9\-]+$')
title: dict[str, str] | None = None
content: dict[str, str] | None = None
page_type: str | None = Field(None, pattern=r'^(page|faq)$')
is_active: bool | None = None
sort_order: int | None = None
icon: str | None = Field(None, max_length=50)
replaces_tab: str | None = Field(None, pattern=r'^(faq|rules|privacy|offer)$')
class ReorderItem(BaseModel):
"""Single item in a reorder request."""
id: int
sort_order: int = Field(ge=0)
class ReorderRequest(BaseModel):
"""Request to bulk-reorder info pages."""
items: list[ReorderItem] = Field(..., min_length=1)
+67 -3
View File
@@ -2,7 +2,50 @@
from datetime import datetime
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
ALLOWED_MEDIA_TYPES = {'photo', 'video', 'document'}
MAX_MEDIA_ITEMS = 10
class TicketMediaItem(BaseModel):
"""Single media attachment in a ticket message."""
type: str = Field(..., description='Media type: photo, video, or document')
file_id: str = Field(..., max_length=255, description='Telegram file_id')
caption: str | None = Field(None, max_length=1000, description='Optional caption')
@model_validator(mode='after')
def validate_type(self) -> 'TicketMediaItem':
if self.type not in ALLOWED_MEDIA_TYPES:
raise ValueError(f'type must be one of: {sorted(ALLOWED_MEDIA_TYPES)}')
return self
def _validate_media_bundle(
media_type: str | None,
media_file_id: str | None,
media_items: list[TicketMediaItem] | None,
) -> None:
"""Shared validator for media-attached request bodies."""
if media_items is not None:
if len(media_items) == 0:
raise ValueError('media_items must not be empty (send null instead)')
if len(media_items) > MAX_MEDIA_ITEMS:
raise ValueError(f'media_items cannot exceed {MAX_MEDIA_ITEMS} entries')
if media_file_id and media_file_id != media_items[0].file_id:
raise ValueError('legacy media_file_id must match media_items[0].file_id')
if media_type and media_type != media_items[0].type:
raise ValueError('legacy media_type must match media_items[0].type')
return
if media_file_id and not media_type:
raise ValueError('media_type is required when media_file_id is provided')
if media_type and not media_file_id:
raise ValueError('media_file_id is required when media_type is provided')
if media_type and media_type not in ALLOWED_MEDIA_TYPES:
raise ValueError(f'media_type must be one of: {sorted(ALLOWED_MEDIA_TYPES)}')
class TicketMessageResponse(BaseModel):
@@ -15,6 +58,7 @@ class TicketMessageResponse(BaseModel):
media_type: str | None = None
media_file_id: str | None = None
media_caption: str | None = None
media_items: list[TicketMediaItem] | None = None
created_at: datetime
class Config:
@@ -69,16 +113,36 @@ class TicketCreateRequest(BaseModel):
"""Request to create a new ticket."""
title: str = Field(..., min_length=3, max_length=255, description='Ticket title')
message: str = Field(..., min_length=10, max_length=4000, description='Initial message')
message: str = Field(default='', max_length=4000, description='Initial message')
media_type: str | None = Field(None, description='Media type: photo, video, document')
media_file_id: str | None = Field(None, description='Telegram file_id of uploaded media')
media_caption: str | None = Field(None, max_length=1000, description='Media caption')
media_items: list[TicketMediaItem] | None = Field(None, description='Multi-media attachments')
@model_validator(mode='after')
def validate_has_content(self) -> 'TicketCreateRequest':
_validate_media_bundle(self.media_type, self.media_file_id, self.media_items)
has_text = bool(self.message.strip())
has_media = bool(self.media_file_id) or bool(self.media_items)
if not has_text and not has_media:
raise ValueError('message or media is required')
return self
class TicketMessageCreateRequest(BaseModel):
"""Request to add message to ticket."""
message: str = Field(..., min_length=1, max_length=4000, description='Message text')
message: str = Field(default='', max_length=4000, description='Message text')
media_type: str | None = Field(None, description='Media type: photo, video, document')
media_file_id: str | None = Field(None, description='Telegram file_id of uploaded media')
media_caption: str | None = Field(None, max_length=1000, description='Media caption')
media_items: list[TicketMediaItem] | None = Field(None, description='Multi-media attachments')
@model_validator(mode='after')
def validate_has_content(self) -> 'TicketMessageCreateRequest':
_validate_media_bundle(self.media_type, self.media_file_id, self.media_items)
has_text = bool(self.message.strip())
has_media = bool(self.media_file_id) or bool(self.media_items)
if not has_text and not has_media:
raise ValueError('message or media is required')
return self
+24
View File
@@ -82,6 +82,21 @@ class UserPromoGroupInfo(BaseModel):
# === User List ===
class SubscriptionListItem(BaseModel):
"""Compact subscription info for user list (multi-tariff mode)."""
id: int
tariff_id: int | None = None
tariff_name: str | None = None
status: str
is_trial: bool = False
end_date: datetime | None = None
days_remaining: int = 0
traffic_used_gb: float = 0
traffic_limit_gb: int = 0
device_limit: int = 0
class UserListItem(BaseModel):
"""User item in list."""
@@ -102,6 +117,15 @@ class UserListItem(BaseModel):
subscription_status: str | None = None
subscription_is_trial: bool = False
subscription_end_date: datetime | None = None
tariff_id: int | None = None
tariff_name: str | None = None
traffic_used_gb: float = 0
traffic_limit_gb: int = 0
device_limit: int = 0
days_remaining: int = 0
# All subscriptions (multi-tariff)
subscriptions: list[SubscriptionListItem] = []
# Promo group
promo_group_id: int | None = None
+444 -37
View File
@@ -1,5 +1,3 @@
import hashlib
import hmac
import html
import os
import re
@@ -67,6 +65,18 @@ class Settings(BaseSettings):
ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID: int | None = None # Промокоды, кампании, промогруппы
ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID: int | None = None # Партнёрки, выводы, админ-действия
# Per-category enable/disable (default True for backwards compatibility)
ADMIN_NOTIFICATIONS_PURCHASES_ENABLED: bool = True
ADMIN_NOTIFICATIONS_RENEWALS_ENABLED: bool = True
ADMIN_NOTIFICATIONS_TRIALS_ENABLED: bool = True
ADMIN_NOTIFICATIONS_BALANCE_ENABLED: bool = True
ADMIN_NOTIFICATIONS_ADDONS_ENABLED: bool = True
ADMIN_NOTIFICATIONS_INFRASTRUCTURE_ENABLED: bool = True
ADMIN_NOTIFICATIONS_ERRORS_ENABLED: bool = True
ADMIN_NOTIFICATIONS_PROMO_ENABLED: bool = True
ADMIN_NOTIFICATIONS_PARTNERS_ENABLED: bool = True
ADMIN_NOTIFICATIONS_TICKETS_ENABLED: bool = True
# Настройки очереди чеков NaloGO
NALOGO_QUEUE_CHECK_INTERVAL: int = 600 # Интервал проверки очереди (секунды, 10 мин)
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
@@ -148,6 +158,9 @@ class Settings(BaseSettings):
DEFAULT_TRAFFIC_RESET_STRATEGY: str = 'MONTH'
RESET_TRAFFIC_ON_PAYMENT: bool = False
RESET_TRAFFIC_ON_TARIFF_SWITCH: bool = True
RESET_DEVICES_ON_RENEWAL: bool = False
TARIFF_SWITCH_UPGRADE_ENABLED: bool = True
TARIFF_SWITCH_DOWNGRADE_ENABLED: bool = True
MAX_DEVICES_LIMIT: int = 20
TRIAL_WARNING_HOURS: int = 2
@@ -273,6 +286,10 @@ class Settings(BaseSettings):
DISPOSABLE_EMAIL_CHECK_ENABLED: bool = True
# Настройки перевыпуска подписки (revoke + regenerate link)
SUBSCRIPTION_REVOKE_ENABLED: bool = True
SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS: int = 900 # 15 minutes
# Настройки простой покупки
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
@@ -564,6 +581,21 @@ class Settings(BaseSettings):
KASSA_AI_SBERPAY_ENABLED: bool = False # SberPay — payment_system_id=43
KASSA_AI_SBERPAY_DISPLAY_NAME: str = 'SberPay (KassaAI)'
# ── Yandex Metrika offline conversions (server → mc.yandex.ru/collect) ──
YANDEX_OFFLINE_CONV_ENABLED: bool = False
YANDEX_OFFLINE_CONV_COUNTER_ID: str = ''
YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET: str = ''
YANDEX_OFFLINE_CONV_START_PREFIX: str = 'utm_ya_'
YANDEX_OFFLINE_CONV_DL: str = ''
YANDEX_OFFLINE_CONV_DT: str = ''
YANDEX_OFFLINE_CONV_CURRENCY: str = 'RUB'
# ── S2S Postback (server-to-server affiliate notifications) ──
S2S_POSTBACK_ENABLED: bool = False
S2S_POSTBACK_REGISTRATION_URL: str = ''
S2S_POSTBACK_TRIAL_URL: str = ''
S2S_POSTBACK_PURCHASE_URL: str = ''
# RioPay (api.riopay.online) v2.0.1
RIOPAY_ENABLED: bool = False
RIOPAY_API_TOKEN: str | None = None # x-api-token header
@@ -588,6 +620,19 @@ class Settings(BaseSettings):
SEVERPAY_RETURN_URL: str | None = None
SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320
# Apple In-App Purchase
APPLE_IAP_ENABLED: bool = False
APPLE_IAP_KEY_ID: str | None = None
APPLE_IAP_ISSUER_ID: str | None = None
APPLE_IAP_BUNDLE_ID: str = 'com.app.client'
APPLE_IAP_PRIVATE_KEY: str | None = None # .p8 key contents (PEM)
APPLE_IAP_PRIVATE_KEY_PATH: str | None = None # Alternative: path to .p8 file
APPLE_IAP_ENVIRONMENT: str = 'Production' # 'Sandbox' or 'Production'
APPLE_IAP_WEBHOOK_PATH: str = '/apple-iap-webhook'
APPLE_IAP_PRODUCTS: str = (
'{"com.app.client.topup.100":10000,"com.app.client.topup.300":30000,"com.app.client.topup.500":50000}'
)
# PayPear (paypear.ru)
PAYPEAR_ENABLED: bool = False
PAYPEAR_SHOP_ID: str | None = None
@@ -611,6 +656,138 @@ class Settings(BaseSettings):
ROLLYPAY_WEBHOOK_PATH: str = '/rollypay-webhook'
ROLLYPAY_RETURN_URL: str | None = None
# Overpay (pay.overpay.io)
OVERPAY_ENABLED: bool = False
OVERPAY_API_URL: str = 'https://api.overpay.io'
OVERPAY_USERNAME: str | None = None
OVERPAY_PASSWORD: str | None = None
OVERPAY_PROJECT_ID: str | None = None
OVERPAY_P12_PATH: str | None = None
OVERPAY_P12_PASSPHRASE: str | None = None
OVERPAY_DISPLAY_NAME: str = 'Overpay'
OVERPAY_CURRENCY: str = 'RUB'
OVERPAY_MIN_AMOUNT_KOPEKS: int = 10000
OVERPAY_MAX_AMOUNT_KOPEKS: int = 10000000
OVERPAY_WEBHOOK_PATH: str = '/overpay-webhook'
OVERPAY_RETURN_URL: str | None = None
OVERPAY_LIFETIME_MINUTES: int = 1440
OVERPAY_PAYMENT_METHODS: str = 'card,fps'
# AuraPay (aurapay.tech)
AURAPAY_ENABLED: bool = False
AURAPAY_API_KEY: str | None = None # X-ApiKey header
AURAPAY_SHOP_ID: str | None = None # X-ShopId header (UUID)
AURAPAY_SECRET_KEY: str | None = None # Secret key #2 for webhook HMAC
AURAPAY_DISPLAY_NAME: str = 'AuraPay'
AURAPAY_CURRENCY: str = 'RUB'
AURAPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
AURAPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
AURAPAY_WEBHOOK_PATH: str = '/aurapay-webhook'
AURAPAY_RETURN_URL: str | None = None
AURAPAY_PAYMENT_LIFETIME_MINUTES: int = 60
AURAPAY_SBP_ENABLED: bool = False
AURAPAY_SBP_DISPLAY_NAME: str = 'СБП (AuraPay)'
AURAPAY_CARD_ENABLED: bool = False
AURAPAY_CARD_DISPLAY_NAME: str = 'Карта (AuraPay)'
# Antilopay (lk.antilopay.com)
ANTILOPAY_ENABLED: bool = False
ANTILOPAY_SECRET_ID: str | None = None
ANTILOPAY_PRIVATE_KEY: str | None = None
ANTILOPAY_PUBLIC_KEY: str | None = None
ANTILOPAY_PROJECT_ID: str | None = None
ANTILOPAY_DISPLAY_NAME: str = 'Antilopay'
ANTILOPAY_PRODUCT_NAME: str = 'VPN подписка'
ANTILOPAY_PRODUCT_TYPE: str = 'services'
ANTILOPAY_CURRENCY: str = 'RUB'
ANTILOPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
ANTILOPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
ANTILOPAY_WEBHOOK_PATH: str = '/antilopay-webhook'
ANTILOPAY_RETURN_URL: str | None = None
ANTILOPAY_PAYMENT_LIFETIME_MINUTES: int = 60
ANTILOPAY_SBP_ENABLED: bool = False
ANTILOPAY_SBP_DISPLAY_NAME: str = 'СБП (Antilopay)'
ANTILOPAY_CARD_ENABLED: bool = False
ANTILOPAY_CARD_DISPLAY_NAME: str = 'Карта (Antilopay)'
ANTILOPAY_SBERPAY_ENABLED: bool = False
ANTILOPAY_SBERPAY_DISPLAY_NAME: str = 'SberPay (Antilopay)'
# Jupiter (FPGate P2P v2.1, app.juppiter.tech)
JUPITER_ENABLED: bool = False
JUPITER_TOKEN: str | None = None
JUPITER_SECRET: str | None = None
JUPITER_BASE_URL: str = 'https://app.juppiter.tech'
JUPITER_METHOD_ID: str | None = None
JUPITER_METHOD_DESCRIPTION: str = 'SBP'
JUPITER_DISPLAY_NAME: str = 'Jupiter'
JUPITER_CURRENCY: str = 'RUB'
JUPITER_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
JUPITER_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
JUPITER_WEBHOOK_PATH: str = '/jupiter-webhook'
JUPITER_RETURN_URL: str | None = None
JUPITER_PAYMENT_LIFETIME_MINUTES: int = 60
JUPITER_FALLBACK_EMAIL: str = 'user@vpn.bot'
JUPITER_FALLBACK_PHONE: str = '0000000000'
JUPITER_FALLBACK_NAME: str = 'User'
JUPITER_SBP_ENABLED: bool = False
JUPITER_SBP_DISPLAY_NAME: str = 'СБП (Jupiter)'
# Donut (Donut P2P, gw.donut.business)
DONUT_ENABLED: bool = False
DONUT_TOKEN: str | None = None
DONUT_SECRET: str | None = None
DONUT_BASE_URL: str = 'https://gw.donut.business'
DONUT_METHOD_ID: str | None = None
DONUT_DISPLAY_NAME: str = 'Donut'
DONUT_CURRENCY: str = 'RUB'
DONUT_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
DONUT_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
DONUT_WEBHOOK_PATH: str = '/donut-webhook'
DONUT_RETURN_URL: str | None = None
DONUT_PAYMENT_LIFETIME_MINUTES: int = 60
# Sub-методы Donut (description в PayIn запросе)
DONUT_CARD_ENABLED: bool = False
DONUT_CARD_DISPLAY_NAME: str = 'Карта (Donut)'
DONUT_SBP_ENABLED: bool = False
DONUT_SBP_DISPLAY_NAME: str = 'СБП (Donut)'
DONUT_SBP_QR_ENABLED: bool = False
DONUT_SBP_QR_DISPLAY_NAME: str = 'СБП QR (Donut)'
# Lava (Lava Business API, gate.lava.ru)
LAVA_ENABLED: bool = False
LAVA_BASE_URL: str = 'https://gate.lava.ru'
LAVA_SHOP_ID: str | None = None # UUID проекта
LAVA_SECRET_KEY: str | None = None # secret_key — для подписи запросов
LAVA_WEBHOOK_SECRET: str | None = None # secret_key_2 — для проверки подписи webhook
LAVA_DISPLAY_NAME: str = 'Lava'
LAVA_CURRENCY: str = 'RUB'
LAVA_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
LAVA_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
LAVA_WEBHOOK_PATH: str = '/lava-webhook'
LAVA_RETURN_URL: str | None = None
LAVA_PAYMENT_LIFETIME_MINUTES: int = 60 # макс 7200 минут (5 дней)
# Sub-методы Lava (фильтр через includeService/excludeService на стороне Lava)
LAVA_CARD_ENABLED: bool = False
LAVA_CARD_DISPLAY_NAME: str = 'Карта (Lava)'
LAVA_SBP_ENABLED: bool = False
LAVA_SBP_DISPLAY_NAME: str = 'СБП (Lava)'
# Etoplatezhi (paymentpage.etoplatezhi.ru)
ETOPLATEZHI_ENABLED: bool = False
ETOPLATEZHI_PROJECT_ID: int | None = None
ETOPLATEZHI_SECRET_KEY: str | None = None
ETOPLATEZHI_DISPLAY_NAME: str = 'Etoplatezhi'
ETOPLATEZHI_CURRENCY: str = 'RUB'
ETOPLATEZHI_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
ETOPLATEZHI_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
ETOPLATEZHI_WEBHOOK_PATH: str = '/etoplatezhi-webhook'
ETOPLATEZHI_RETURN_URL: str | None = None
ETOPLATEZHI_PAYMENT_LIFETIME_MINUTES: int = 60
ETOPLATEZHI_SBP_ENABLED: bool = False
ETOPLATEZHI_SBP_DISPLAY_NAME: str = 'СБП (Etoplatezhi)'
ETOPLATEZHI_CARD_ENABLED: bool = False
ETOPLATEZHI_CARD_DISPLAY_NAME: str = 'Карта (Etoplatezhi)'
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
CABINET_BUTTON_STYLE: str = ''
@@ -755,6 +932,7 @@ class Settings(BaseSettings):
WEBHOOK_URL: str | None = None
WEBHOOK_PATH: str = '/webhook'
WEBHOOK_SECRET_TOKEN: str | None = None
WEBHOOK_IP: str | None = None # IP адрес для setWebhook, чтобы Telegram не резолвил домен
WEBHOOK_DROP_PENDING_UPDATES: bool = True
WEBHOOK_MAX_QUEUE_SIZE: int = 1024
WEBHOOK_WORKERS: int = 4
@@ -795,9 +973,6 @@ class Settings(BaseSettings):
BACKUP_SEND_TOPIC_ID: int | None = None
BACKUP_ARCHIVE_PASSWORD: str | None = None
EXTERNAL_ADMIN_TOKEN: str | None = None
EXTERNAL_ADMIN_TOKEN_BOT_ID: int | None = None
# Cabinet (Personal Account) settings
CABINET_ENABLED: bool = False
CABINET_JWT_SECRET: str | None = None
@@ -1218,7 +1393,13 @@ class Settings(BaseSettings):
if not sanitized_username:
sanitized_username = _sanitize(f'user_{identifier}')
return sanitized_username[:36].strip('_-') or 'user'
result = sanitized_username[:36].strip('_-') or 'user'
# RemnaWave требует username минимум 3 символа
if len(result) < 3:
result = f'{result}_{identifier}'[:36].strip('_-')
return result or 'user'
@staticmethod
def parse_daily_time_list(raw_value: str | None) -> list[time]:
@@ -1598,37 +1779,6 @@ class Settings(BaseSettings):
def get_app_config_cache_ttl(self) -> int:
return self.APP_CONFIG_CACHE_TTL
def build_external_admin_token(self, bot_username: str) -> str:
"""Генерирует детерминированный и криптографически стойкий токен внешней админки."""
normalized = (bot_username or '').strip().lstrip('@').lower()
if not normalized:
raise ValueError('Bot username is required to build external admin token')
secret = (self.BOT_TOKEN or '').strip()
if not secret:
raise ValueError('Bot token is required to build external admin token')
digest = hmac.new(
key=secret.encode('utf-8'),
msg=f'remnawave.external_admin::{normalized}'.encode(),
digestmod=hashlib.sha256,
).hexdigest()
return digest[:48]
def get_external_admin_token(self) -> str | None:
token = (self.EXTERNAL_ADMIN_TOKEN or '').strip()
return token or None
def get_external_admin_bot_id(self) -> int | None:
try:
return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None
except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений
logger.warning(
'Некорректный идентификатор бота для внешней админки',
EXTERNAL_ADMIN_TOKEN_BOT_ID=self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
)
return None
def is_traffic_selectable(self) -> bool:
return self.TRAFFIC_SELECTION_MODE.lower() == 'selectable'
@@ -1717,6 +1867,10 @@ class Settings(BaseSettings):
def get_disabled_mode_device_limit(self) -> int | None:
return self.get_devices_selection_disabled_amount()
def is_subscription_revoke_enabled(self) -> bool:
"""Проверяет, включен ли перевыпуск подписки."""
return self.SUBSCRIPTION_REVOKE_ENABLED
def is_multi_tariff_enabled(self) -> bool:
"""Проверяет, включен ли мультитарифный режим."""
return self.MULTI_TARIFF_ENABLED and self.SALES_MODE == 'tariffs'
@@ -1996,6 +2150,34 @@ class Settings(BaseSettings):
def get_severpay_display_name_html(self) -> str:
return html.escape(self.get_severpay_display_name())
def is_apple_iap_enabled(self) -> bool:
return (
self.APPLE_IAP_ENABLED
and self.APPLE_IAP_KEY_ID is not None
and self.APPLE_IAP_ISSUER_ID is not None
and (self.APPLE_IAP_PRIVATE_KEY is not None or self.APPLE_IAP_PRIVATE_KEY_PATH is not None)
)
def get_apple_iap_products(self) -> dict[str, int]:
"""Return mapping of Apple product ID -> kopeks amount."""
import json as _json
try:
return _json.loads(self.APPLE_IAP_PRODUCTS)
except Exception:
return {}
def get_apple_iap_private_key(self) -> str | None:
"""Return the .p8 private key contents."""
if self.APPLE_IAP_PRIVATE_KEY:
return self.APPLE_IAP_PRIVATE_KEY
if self.APPLE_IAP_PRIVATE_KEY_PATH:
try:
return Path(self.APPLE_IAP_PRIVATE_KEY_PATH).read_text().strip()
except Exception:
return None
return None
def is_paypear_enabled(self) -> bool:
return self.PAYPEAR_ENABLED and self.PAYPEAR_SHOP_ID is not None and self.PAYPEAR_SECRET_KEY is not None
@@ -2016,6 +2198,231 @@ class Settings(BaseSettings):
def get_rollypay_display_name_html(self) -> str:
return html.escape(self.get_rollypay_display_name())
def is_overpay_enabled(self) -> bool:
return (
self.OVERPAY_ENABLED
and self.OVERPAY_USERNAME is not None
and self.OVERPAY_PASSWORD is not None
and self.OVERPAY_PROJECT_ID is not None
)
def get_overpay_display_name(self) -> str:
name = (self.OVERPAY_DISPLAY_NAME or '').strip()
return name if name else 'Overpay'
def get_overpay_display_name_html(self) -> str:
return html.escape(self.get_overpay_display_name())
def is_aurapay_enabled(self) -> bool:
return (
self.AURAPAY_ENABLED
and self.AURAPAY_API_KEY is not None
and self.AURAPAY_SHOP_ID is not None
and self.AURAPAY_SECRET_KEY is not None
)
def get_aurapay_display_name(self) -> str:
name = (self.AURAPAY_DISPLAY_NAME or '').strip()
return name if name else 'AuraPay'
def get_aurapay_display_name_html(self) -> str:
return html.escape(self.get_aurapay_display_name())
def is_aurapay_sbp_enabled(self) -> bool:
return self.AURAPAY_SBP_ENABLED and self.is_aurapay_enabled()
def get_aurapay_sbp_display_name(self) -> str:
name = (self.AURAPAY_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (AuraPay)'
def get_aurapay_sbp_display_name_html(self) -> str:
return html.escape(self.get_aurapay_sbp_display_name())
def is_aurapay_card_enabled(self) -> bool:
return self.AURAPAY_CARD_ENABLED and self.is_aurapay_enabled()
def get_aurapay_card_display_name(self) -> str:
name = (self.AURAPAY_CARD_DISPLAY_NAME or '').strip()
return name or 'Карта (AuraPay)'
def get_aurapay_card_display_name_html(self) -> str:
return html.escape(self.get_aurapay_card_display_name())
def is_antilopay_enabled(self) -> bool:
return (
self.ANTILOPAY_ENABLED
and self.ANTILOPAY_SECRET_ID is not None
and self.ANTILOPAY_PRIVATE_KEY is not None
and self.ANTILOPAY_PUBLIC_KEY is not None
and self.ANTILOPAY_PROJECT_ID is not None
)
def get_antilopay_display_name(self) -> str:
name = (self.ANTILOPAY_DISPLAY_NAME or '').strip()
return name if name else 'Antilopay'
def get_antilopay_display_name_html(self) -> str:
return html.escape(self.get_antilopay_display_name())
def is_antilopay_sbp_enabled(self) -> bool:
return self.ANTILOPAY_SBP_ENABLED and self.is_antilopay_enabled()
def get_antilopay_sbp_display_name(self) -> str:
name = (self.ANTILOPAY_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (Antilopay)'
def get_antilopay_sbp_display_name_html(self) -> str:
return html.escape(self.get_antilopay_sbp_display_name())
def is_antilopay_card_enabled(self) -> bool:
return self.ANTILOPAY_CARD_ENABLED and self.is_antilopay_enabled()
def get_antilopay_card_display_name(self) -> str:
name = (self.ANTILOPAY_CARD_DISPLAY_NAME or '').strip()
return name or 'Карта (Antilopay)'
def get_antilopay_card_display_name_html(self) -> str:
return html.escape(self.get_antilopay_card_display_name())
def is_antilopay_sberpay_enabled(self) -> bool:
return self.ANTILOPAY_SBERPAY_ENABLED and self.is_antilopay_enabled()
def get_antilopay_sberpay_display_name(self) -> str:
name = (self.ANTILOPAY_SBERPAY_DISPLAY_NAME or '').strip()
return name or 'SberPay (Antilopay)'
def get_antilopay_sberpay_display_name_html(self) -> str:
return html.escape(self.get_antilopay_sberpay_display_name())
def is_jupiter_enabled(self) -> bool:
return self.JUPITER_ENABLED and self.JUPITER_TOKEN is not None and self.JUPITER_SECRET is not None
def get_jupiter_display_name(self) -> str:
name = (self.JUPITER_DISPLAY_NAME or '').strip()
return name if name else 'Jupiter'
def get_jupiter_display_name_html(self) -> str:
return html.escape(self.get_jupiter_display_name())
def is_jupiter_sbp_enabled(self) -> bool:
return self.JUPITER_SBP_ENABLED and self.is_jupiter_enabled()
def get_jupiter_sbp_display_name(self) -> str:
name = (self.JUPITER_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (Jupiter)'
def get_jupiter_sbp_display_name_html(self) -> str:
return html.escape(self.get_jupiter_sbp_display_name())
def is_donut_enabled(self) -> bool:
return self.DONUT_ENABLED and self.DONUT_TOKEN is not None and self.DONUT_SECRET is not None
def get_donut_display_name(self) -> str:
name = (self.DONUT_DISPLAY_NAME or '').strip()
return name if name else 'Donut'
def get_donut_display_name_html(self) -> str:
return html.escape(self.get_donut_display_name())
def is_donut_card_enabled(self) -> bool:
return self.DONUT_CARD_ENABLED and self.is_donut_enabled()
def get_donut_card_display_name(self) -> str:
name = (self.DONUT_CARD_DISPLAY_NAME or '').strip()
return name or 'Карта (Donut)'
def get_donut_card_display_name_html(self) -> str:
return html.escape(self.get_donut_card_display_name())
def is_donut_sbp_enabled(self) -> bool:
return self.DONUT_SBP_ENABLED and self.is_donut_enabled()
def get_donut_sbp_display_name(self) -> str:
name = (self.DONUT_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (Donut)'
def get_donut_sbp_display_name_html(self) -> str:
return html.escape(self.get_donut_sbp_display_name())
def is_donut_sbp_qr_enabled(self) -> bool:
return self.DONUT_SBP_QR_ENABLED and self.is_donut_enabled()
def get_donut_sbp_qr_display_name(self) -> str:
name = (self.DONUT_SBP_QR_DISPLAY_NAME or '').strip()
return name or 'СБП QR (Donut)'
def get_donut_sbp_qr_display_name_html(self) -> str:
return html.escape(self.get_donut_sbp_qr_display_name())
def is_lava_enabled(self) -> bool:
return (
self.LAVA_ENABLED
and self.LAVA_SHOP_ID is not None
and self.LAVA_SECRET_KEY is not None
and self.LAVA_WEBHOOK_SECRET is not None
)
def get_lava_display_name(self) -> str:
name = (self.LAVA_DISPLAY_NAME or '').strip()
return name if name else 'Lava'
def get_lava_display_name_html(self) -> str:
return html.escape(self.get_lava_display_name())
def is_lava_card_enabled(self) -> bool:
return self.LAVA_CARD_ENABLED and self.is_lava_enabled()
def get_lava_card_display_name(self) -> str:
name = (self.LAVA_CARD_DISPLAY_NAME or '').strip()
return name or 'Карта (Lava)'
def get_lava_card_display_name_html(self) -> str:
return html.escape(self.get_lava_card_display_name())
def is_lava_sbp_enabled(self) -> bool:
return self.LAVA_SBP_ENABLED and self.is_lava_enabled()
def get_lava_sbp_display_name(self) -> str:
name = (self.LAVA_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (Lava)'
def get_lava_sbp_display_name_html(self) -> str:
return html.escape(self.get_lava_sbp_display_name())
def is_etoplatezhi_enabled(self) -> bool:
return (
self.ETOPLATEZHI_ENABLED
and self.ETOPLATEZHI_PROJECT_ID is not None
and self.ETOPLATEZHI_SECRET_KEY is not None
)
def get_etoplatezhi_display_name(self) -> str:
name = (self.ETOPLATEZHI_DISPLAY_NAME or '').strip()
return name if name else 'Etoplatezhi'
def get_etoplatezhi_display_name_html(self) -> str:
return html.escape(self.get_etoplatezhi_display_name())
def is_etoplatezhi_sbp_enabled(self) -> bool:
return self.ETOPLATEZHI_SBP_ENABLED and self.is_etoplatezhi_enabled()
def get_etoplatezhi_sbp_display_name(self) -> str:
name = (self.ETOPLATEZHI_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (Etoplatezhi)'
def get_etoplatezhi_sbp_display_name_html(self) -> str:
return html.escape(self.get_etoplatezhi_sbp_display_name())
def is_etoplatezhi_card_enabled(self) -> bool:
return self.ETOPLATEZHI_CARD_ENABLED and self.is_etoplatezhi_enabled()
def get_etoplatezhi_card_display_name(self) -> str:
name = (self.ETOPLATEZHI_CARD_DISPLAY_NAME or '').strip()
return name or 'Карта (Etoplatezhi)'
def get_etoplatezhi_card_display_name_html(self) -> str:
return html.escape(self.get_etoplatezhi_card_display_name())
def is_kassa_ai_sbp_enabled(self) -> bool:
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
+159
View File
@@ -0,0 +1,159 @@
"""CRUD операции для платежей Antilopay."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import AntilopayPayment
logger = structlog.get_logger(__name__)
async def create_antilopay_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
antilopay_payment_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> AntilopayPayment:
"""Создает запись о платеже Antilopay."""
payment = AntilopayPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
antilopay_payment_id=antilopay_payment_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж Antilopay', order_id=order_id, user_id=user_id)
return payment
async def get_antilopay_payment_by_order_id(db: AsyncSession, order_id: str) -> AntilopayPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(AntilopayPayment).where(AntilopayPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_antilopay_payment_by_invoice_id(db: AsyncSession, antilopay_payment_id: str) -> AntilopayPayment | None:
"""Получает платеж по ID от Antilopay."""
result = await db.execute(
select(AntilopayPayment).where(AntilopayPayment.antilopay_payment_id == antilopay_payment_id)
)
return result.scalar_one_or_none()
async def get_antilopay_payment_by_id(db: AsyncSession, payment_id: int) -> AntilopayPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(AntilopayPayment).where(AntilopayPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_antilopay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> AntilopayPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(
select(AntilopayPayment)
.where(AntilopayPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_antilopay_payment_status(
db: AsyncSession,
payment: AntilopayPayment,
*,
status: str,
is_paid: bool | None = None,
antilopay_payment_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> AntilopayPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if antilopay_payment_id is not None:
payment.antilopay_payment_id = antilopay_payment_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен статус платежа Antilopay',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_antilopay_payments(db: AsyncSession, user_id: int) -> list[AntilopayPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(AntilopayPayment).where(
AntilopayPayment.user_id == user_id,
AntilopayPayment.status == 'pending',
AntilopayPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_antilopay_payments(
db: AsyncSession,
) -> list[AntilopayPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(AntilopayPayment).where(
AntilopayPayment.status == 'pending',
AntilopayPayment.is_paid == False,
AntilopayPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_antilopay_payment_to_transaction(
db: AsyncSession,
*,
payment: AntilopayPayment,
transaction_id: int,
) -> AntilopayPayment:
"""Связывает платеж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+83
View File
@@ -0,0 +1,83 @@
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import AppleTransaction
logger = structlog.get_logger(__name__)
async def create_apple_transaction(
db: AsyncSession,
user_id: int,
transaction_id: str,
product_id: str,
bundle_id: str,
amount_kopeks: int,
environment: str,
original_transaction_id: str | None = None,
transaction_id_fk: int | None = None,
) -> AppleTransaction:
apple_txn = AppleTransaction(
user_id=user_id,
transaction_id=transaction_id,
original_transaction_id=original_transaction_id,
product_id=product_id,
bundle_id=bundle_id,
amount_kopeks=amount_kopeks,
environment=environment,
status='verified',
is_paid=True,
paid_at=datetime.now(UTC),
transaction_id_fk=transaction_id_fk,
)
db.add(apple_txn)
await db.flush()
await db.refresh(apple_txn)
logger.info(
'Создана Apple транзакция',
transaction_id=transaction_id,
product_id=product_id,
amount_kopeks=amount_kopeks,
user_id=user_id,
)
return apple_txn
async def get_apple_transaction_by_transaction_id(db: AsyncSession, transaction_id: str) -> AppleTransaction | None:
result = await db.execute(select(AppleTransaction).where(AppleTransaction.transaction_id == transaction_id))
return result.scalar_one_or_none()
async def get_apple_transaction_by_transaction_id_for_update(
db: AsyncSession, transaction_id: str
) -> AppleTransaction | None:
"""Get apple transaction with FOR UPDATE lock for safe concurrent access."""
result = await db.execute(
select(AppleTransaction).where(AppleTransaction.transaction_id == transaction_id).with_for_update()
)
return result.scalar_one_or_none()
async def mark_apple_transaction_refunded(db: AsyncSession, transaction_id: str) -> AppleTransaction | None:
"""Mark an Apple transaction as refunded. Returns the transaction or None if not found."""
apple_txn = await get_apple_transaction_by_transaction_id(db, transaction_id)
if not apple_txn:
return None
apple_txn.status = 'refunded'
apple_txn.refunded_at = datetime.now(UTC)
await db.flush()
await db.refresh(apple_txn)
logger.info(
'Apple транзакция помечена как возврат',
transaction_id=transaction_id,
user_id=apple_txn.user_id,
)
return apple_txn
+157
View File
@@ -0,0 +1,157 @@
"""CRUD операции для платежей AuraPay."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import AuraPayPayment
logger = structlog.get_logger(__name__)
async def create_aurapay_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
aurapay_invoice_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> AuraPayPayment:
"""Создает запись о платеже AuraPay."""
payment = AuraPayPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
aurapay_invoice_id=aurapay_invoice_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж AuraPay', order_id=order_id, user_id=user_id)
return payment
async def get_aurapay_payment_by_order_id(db: AsyncSession, order_id: str) -> AuraPayPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(AuraPayPayment).where(AuraPayPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_aurapay_payment_by_invoice_id(db: AsyncSession, aurapay_invoice_id: str) -> AuraPayPayment | None:
"""Получает платеж по UUID от AuraPay."""
result = await db.execute(select(AuraPayPayment).where(AuraPayPayment.aurapay_invoice_id == aurapay_invoice_id))
return result.scalar_one_or_none()
async def get_aurapay_payment_by_id(db: AsyncSession, payment_id: int) -> AuraPayPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(AuraPayPayment).where(AuraPayPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_aurapay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> AuraPayPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(
select(AuraPayPayment)
.where(AuraPayPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_aurapay_payment_status(
db: AsyncSession,
payment: AuraPayPayment,
*,
status: str,
is_paid: bool | None = None,
aurapay_invoice_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> AuraPayPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if aurapay_invoice_id is not None:
payment.aurapay_invoice_id = aurapay_invoice_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен статус платежа AuraPay',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_aurapay_payments(db: AsyncSession, user_id: int) -> list[AuraPayPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(AuraPayPayment).where(
AuraPayPayment.user_id == user_id,
AuraPayPayment.status == 'pending',
AuraPayPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_aurapay_payments(
db: AsyncSession,
) -> list[AuraPayPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(AuraPayPayment).where(
AuraPayPayment.status == 'pending',
AuraPayPayment.is_paid == False,
AuraPayPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_aurapay_payment_to_transaction(
db: AsyncSession,
*,
payment: AuraPayPayment,
transaction_id: int,
) -> AuraPayPayment:
"""Связывает платеж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+155
View File
@@ -0,0 +1,155 @@
"""CRUD операции для платежей Donut (Donut P2P)."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import DonutPayment
logger = structlog.get_logger(__name__)
async def create_donut_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
donut_transaction_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> DonutPayment:
"""Создаёт запись о платеже Donut."""
payment = DonutPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
donut_transaction_id=donut_transaction_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж Donut', order_id=order_id, user_id=user_id)
return payment
async def get_donut_payment_by_order_id(db: AsyncSession, order_id: str) -> DonutPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(DonutPayment).where(DonutPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_donut_payment_by_invoice_id(db: AsyncSession, donut_transaction_id: str) -> DonutPayment | None:
"""Получает платёж по transaction_id, выданному Donut."""
result = await db.execute(select(DonutPayment).where(DonutPayment.donut_transaction_id == donut_transaction_id))
return result.scalar_one_or_none()
async def get_donut_payment_by_id(db: AsyncSession, payment_id: int) -> DonutPayment | None:
"""Получает платеж по локальному ID."""
result = await db.execute(select(DonutPayment).where(DonutPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_donut_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> DonutPayment | None:
"""Получает платёж с блокировкой FOR UPDATE."""
result = await db.execute(
select(DonutPayment)
.where(DonutPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_donut_payment_status(
db: AsyncSession,
payment: DonutPayment,
*,
status: str,
is_paid: bool | None = None,
donut_transaction_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> DonutPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if donut_transaction_id is not None:
payment.donut_transaction_id = donut_transaction_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлён статус платежа Donut',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_donut_payments(db: AsyncSession, user_id: int) -> list[DonutPayment]:
"""Возвращает незавершённые платежи пользователя."""
result = await db.execute(
select(DonutPayment).where(
DonutPayment.user_id == user_id,
DonutPayment.status == 'pending',
DonutPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_donut_payments(db: AsyncSession) -> list[DonutPayment]:
"""Возвращает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(DonutPayment).where(
DonutPayment.status == 'pending',
DonutPayment.is_paid == False,
DonutPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_donut_payment_to_transaction(
db: AsyncSession,
*,
payment: DonutPayment,
transaction_id: int,
) -> DonutPayment:
"""Связывает платёж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+161
View File
@@ -0,0 +1,161 @@
"""CRUD операции для платежей Etoplatezhi."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import EtoplatezhiPayment
logger = structlog.get_logger(__name__)
async def create_etoplatezhi_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
etoplatezhi_payment_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> EtoplatezhiPayment:
"""Создает запись о платеже Etoplatezhi."""
payment = EtoplatezhiPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
etoplatezhi_payment_id=etoplatezhi_payment_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж Etoplatezhi', order_id=order_id, user_id=user_id)
return payment
async def get_etoplatezhi_payment_by_order_id(db: AsyncSession, order_id: str) -> EtoplatezhiPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(EtoplatezhiPayment).where(EtoplatezhiPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_etoplatezhi_payment_by_invoice_id(
db: AsyncSession, etoplatezhi_payment_id: str
) -> EtoplatezhiPayment | None:
"""Получает платеж по ID от Etoplatezhi."""
result = await db.execute(
select(EtoplatezhiPayment).where(EtoplatezhiPayment.etoplatezhi_payment_id == etoplatezhi_payment_id)
)
return result.scalar_one_or_none()
async def get_etoplatezhi_payment_by_id(db: AsyncSession, payment_id: int) -> EtoplatezhiPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(EtoplatezhiPayment).where(EtoplatezhiPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_etoplatezhi_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> EtoplatezhiPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(
select(EtoplatezhiPayment)
.where(EtoplatezhiPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_etoplatezhi_payment_status(
db: AsyncSession,
payment: EtoplatezhiPayment,
*,
status: str,
is_paid: bool | None = None,
etoplatezhi_payment_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> EtoplatezhiPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if etoplatezhi_payment_id is not None:
payment.etoplatezhi_payment_id = etoplatezhi_payment_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен статус платежа Etoplatezhi',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_etoplatezhi_payments(db: AsyncSession, user_id: int) -> list[EtoplatezhiPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(EtoplatezhiPayment).where(
EtoplatezhiPayment.user_id == user_id,
EtoplatezhiPayment.status == 'pending',
EtoplatezhiPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_etoplatezhi_payments(
db: AsyncSession,
) -> list[EtoplatezhiPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(EtoplatezhiPayment).where(
EtoplatezhiPayment.status == 'pending',
EtoplatezhiPayment.is_paid == False,
EtoplatezhiPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_etoplatezhi_payment_to_transaction(
db: AsyncSession,
*,
payment: EtoplatezhiPayment,
transaction_id: int,
) -> EtoplatezhiPayment:
"""Связывает платеж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+214
View File
@@ -0,0 +1,214 @@
"""CRUD operations for info pages."""
from datetime import UTC, datetime
from typing import Any
import structlog
from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import InfoPage
logger = structlog.get_logger(__name__)
# Fields that can be set via update_info_page
_ALLOWED_UPDATE_FIELDS: frozenset[str] = frozenset(
{
'slug',
'title',
'content',
'page_type',
'is_active',
'sort_order',
'icon',
'replaces_tab',
}
)
# Fields that can be explicitly set to None
_NULLABLE_UPDATE_FIELDS: frozenset[str] = frozenset(
{
'icon',
'replaces_tab',
}
)
async def create_info_page(
db: AsyncSession,
*,
slug: str,
title: dict[str, str],
content: dict[str, str],
page_type: str = 'page',
is_active: bool = True,
sort_order: int = 0,
icon: str | None = None,
replaces_tab: str | None = None,
) -> InfoPage:
"""Create a new info page.
Raises:
IntegrityError: if slug is not unique (caller must handle).
"""
page = InfoPage(
slug=slug,
title=title,
content=content,
page_type=page_type,
is_active=is_active,
sort_order=sort_order,
icon=icon,
replaces_tab=replaces_tab,
)
db.add(page)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
await db.refresh(page)
logger.info('Created info page', page_id=page.id, slug=page.slug)
return page
async def get_info_page_by_id(db: AsyncSession, page_id: int) -> InfoPage | None:
"""Get an info page by ID."""
result = await db.execute(select(InfoPage).where(InfoPage.id == page_id))
return result.scalar_one_or_none()
async def get_info_page_by_slug(db: AsyncSession, slug: str) -> InfoPage | None:
"""Get an info page by slug."""
result = await db.execute(select(InfoPage).where(InfoPage.slug == slug))
return result.scalar_one_or_none()
async def get_all_info_pages(
db: AsyncSession,
*,
include_inactive: bool = False,
page_type: str | None = None,
) -> list[InfoPage]:
"""Get all info pages, ordered by sort_order ascending."""
stmt = select(InfoPage)
if not include_inactive:
stmt = stmt.where(InfoPage.is_active.is_(True))
if page_type is not None:
stmt = stmt.where(InfoPage.page_type == page_type)
stmt = stmt.order_by(InfoPage.sort_order.asc(), InfoPage.id.asc())
result = await db.execute(stmt)
return list(result.scalars().all())
async def update_info_page(
db: AsyncSession,
page_id: int,
**kwargs: Any,
) -> InfoPage | None:
"""Update an info page. Only whitelisted fields are applied.
Raises:
IntegrityError: if slug conflicts with another page (caller must handle).
"""
update_data: dict[str, Any] = {}
for key, value in kwargs.items():
if key not in _ALLOWED_UPDATE_FIELDS:
continue
if value is None and key not in _NULLABLE_UPDATE_FIELDS:
continue
update_data[key] = value
if not update_data:
return await get_info_page_by_id(db, page_id)
update_data['updated_at'] = datetime.now(UTC)
await db.execute(update(InfoPage).where(InfoPage.id == page_id).values(**update_data))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
page = await get_info_page_by_id(db, page_id)
if page:
logger.info(
'Updated info page',
page_id=page_id,
updated_fields=list(update_data.keys()),
)
return page
async def delete_info_page(db: AsyncSession, page_id: int) -> None:
"""Delete an info page."""
await db.execute(delete(InfoPage).where(InfoPage.id == page_id))
await db.commit()
logger.info('Deleted info page', page_id=page_id)
async def get_tab_replacements(db: AsyncSession) -> dict[str, str | None]:
"""Return a mapping of tab name to info page slug for active pages with replaces_tab set.
Returns dict like ``{'faq': 'my-custom-faq', 'rules': None, 'privacy': None, 'offer': None}``.
"""
result_map: dict[str, str | None] = {
'faq': None,
'rules': None,
'privacy': None,
'offer': None,
}
stmt = select(InfoPage).where(
InfoPage.is_active.is_(True),
InfoPage.replaces_tab.isnot(None),
)
result = await db.execute(stmt)
for page in result.scalars().all():
if page.replaces_tab in result_map:
result_map[page.replaces_tab] = page.slug
return result_map
async def clear_replaces_tab(db: AsyncSession, tab: str, *, exclude_page_id: int | None = None) -> None:
"""Clear replaces_tab for all pages that currently replace the given tab.
Optionally exclude a specific page (the one being saved).
"""
stmt = (
update(InfoPage)
.where(
InfoPage.replaces_tab == tab,
)
.values(replaces_tab=None, updated_at=datetime.now(UTC))
)
if exclude_page_id is not None:
stmt = stmt.where(InfoPage.id != exclude_page_id)
await db.execute(stmt)
async def reorder_info_pages(db: AsyncSession, items: list[dict]) -> None:
"""Bulk update sort_order for info pages.
Each item must have ``id`` and ``sort_order`` attributes.
"""
for item in items:
page_id = item.id if hasattr(item, 'id') else item.get('id')
sort_order = item.sort_order if hasattr(item, 'sort_order') else item.get('sort_order')
if page_id is None or sort_order is None:
continue
await db.execute(
update(InfoPage).where(InfoPage.id == page_id).values(sort_order=sort_order, updated_at=datetime.now(UTC))
)
await db.commit()
logger.info('Reordered info pages', count=len(items))
+157
View File
@@ -0,0 +1,157 @@
"""CRUD операции для платежей Jupiter (FPGate P2P v2.1)."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import JupiterPayment
logger = structlog.get_logger(__name__)
async def create_jupiter_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
jupiter_transaction_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> JupiterPayment:
"""Создаёт запись о платеже Jupiter."""
payment = JupiterPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
jupiter_transaction_id=jupiter_transaction_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж Jupiter', order_id=order_id, user_id=user_id)
return payment
async def get_jupiter_payment_by_order_id(db: AsyncSession, order_id: str) -> JupiterPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(JupiterPayment).where(JupiterPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_jupiter_payment_by_invoice_id(db: AsyncSession, jupiter_transaction_id: str) -> JupiterPayment | None:
"""Получает платёж по transaction_id, выданному Jupiter."""
result = await db.execute(
select(JupiterPayment).where(JupiterPayment.jupiter_transaction_id == jupiter_transaction_id)
)
return result.scalar_one_or_none()
async def get_jupiter_payment_by_id(db: AsyncSession, payment_id: int) -> JupiterPayment | None:
"""Получает платеж по локальному ID."""
result = await db.execute(select(JupiterPayment).where(JupiterPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_jupiter_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> JupiterPayment | None:
"""Получает платёж с блокировкой FOR UPDATE."""
result = await db.execute(
select(JupiterPayment)
.where(JupiterPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_jupiter_payment_status(
db: AsyncSession,
payment: JupiterPayment,
*,
status: str,
is_paid: bool | None = None,
jupiter_transaction_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> JupiterPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if jupiter_transaction_id is not None:
payment.jupiter_transaction_id = jupiter_transaction_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлён статус платежа Jupiter',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_jupiter_payments(db: AsyncSession, user_id: int) -> list[JupiterPayment]:
"""Возвращает незавершённые платежи пользователя."""
result = await db.execute(
select(JupiterPayment).where(
JupiterPayment.user_id == user_id,
JupiterPayment.status == 'pending',
JupiterPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_jupiter_payments(db: AsyncSession) -> list[JupiterPayment]:
"""Возвращает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(JupiterPayment).where(
JupiterPayment.status == 'pending',
JupiterPayment.is_paid == False,
JupiterPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_jupiter_payment_to_transaction(
db: AsyncSession,
*,
payment: JupiterPayment,
transaction_id: int,
) -> JupiterPayment:
"""Связывает платёж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+5
View File
@@ -76,6 +76,11 @@ _LANDING_UPDATABLE_FIELDS = frozenset(
'discount_ends_at',
'discount_badge_text',
'background_config',
'sticky_pay_button',
'analytics_view_enabled',
'analytics_view_goal',
'analytics_click_enabled',
'analytics_click_goal',
}
)
+155
View File
@@ -0,0 +1,155 @@
"""CRUD операции для платежей Lava (Lava Business)."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import LavaPayment
logger = structlog.get_logger(__name__)
async def create_lava_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
lava_invoice_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> LavaPayment:
"""Создаёт запись о платеже Lava."""
payment = LavaPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
lava_invoice_id=lava_invoice_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж Lava', order_id=order_id, user_id=user_id)
return payment
async def get_lava_payment_by_order_id(db: AsyncSession, order_id: str) -> LavaPayment | None:
"""Получает платёж по нашему orderId."""
result = await db.execute(select(LavaPayment).where(LavaPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_lava_payment_by_invoice_id(db: AsyncSession, lava_invoice_id: str) -> LavaPayment | None:
"""Получает платёж по invoice_id, выданному Lava."""
result = await db.execute(select(LavaPayment).where(LavaPayment.lava_invoice_id == lava_invoice_id))
return result.scalar_one_or_none()
async def get_lava_payment_by_id(db: AsyncSession, payment_id: int) -> LavaPayment | None:
"""Получает платёж по локальному ID."""
result = await db.execute(select(LavaPayment).where(LavaPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_lava_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> LavaPayment | None:
"""Получает платёж с FOR UPDATE-блокировкой."""
result = await db.execute(
select(LavaPayment)
.where(LavaPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_lava_payment_status(
db: AsyncSession,
payment: LavaPayment,
*,
status: str,
is_paid: bool | None = None,
lava_invoice_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> LavaPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if lava_invoice_id is not None:
payment.lava_invoice_id = lava_invoice_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлён статус платежа Lava',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_lava_payments(db: AsyncSession, user_id: int) -> list[LavaPayment]:
"""Возвращает незавершённые платежи пользователя."""
result = await db.execute(
select(LavaPayment).where(
LavaPayment.user_id == user_id,
LavaPayment.status == 'pending',
LavaPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_lava_payments(db: AsyncSession) -> list[LavaPayment]:
"""Возвращает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(LavaPayment).where(
LavaPayment.status == 'pending',
LavaPayment.is_paid == False,
LavaPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_lava_payment_to_transaction(
db: AsyncSession,
*,
payment: LavaPayment,
transaction_id: int,
) -> LavaPayment:
"""Связывает платёж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+157
View File
@@ -0,0 +1,157 @@
"""CRUD операции для платежей Overpay."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import OverpayPayment
logger = structlog.get_logger(__name__)
async def create_overpay_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
overpay_payment_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> OverpayPayment:
"""Создает запись о платеже Overpay."""
payment = OverpayPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
overpay_payment_id=overpay_payment_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж Overpay', order_id=order_id, user_id=user_id)
return payment
async def get_overpay_payment_by_order_id(db: AsyncSession, order_id: str) -> OverpayPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(OverpayPayment).where(OverpayPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_overpay_payment_by_overpay_id(db: AsyncSession, overpay_payment_id: str) -> OverpayPayment | None:
"""Получает платеж по ID от Overpay."""
result = await db.execute(select(OverpayPayment).where(OverpayPayment.overpay_payment_id == overpay_payment_id))
return result.scalar_one_or_none()
async def get_overpay_payment_by_id(db: AsyncSession, payment_id: int) -> OverpayPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(OverpayPayment).where(OverpayPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_overpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> OverpayPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(
select(OverpayPayment)
.where(OverpayPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_overpay_payment_status(
db: AsyncSession,
payment: OverpayPayment,
*,
status: str,
is_paid: bool | None = None,
overpay_payment_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> OverpayPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if overpay_payment_id is not None:
payment.overpay_payment_id = overpay_payment_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен статус платежа Overpay',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_overpay_payments(db: AsyncSession, user_id: int) -> list[OverpayPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(OverpayPayment).where(
OverpayPayment.user_id == user_id,
OverpayPayment.status == 'pending',
OverpayPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_overpay_payments(
db: AsyncSession,
) -> list[OverpayPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(OverpayPayment).where(
OverpayPayment.status == 'pending',
OverpayPayment.is_paid == False,
OverpayPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_overpay_payment_to_transaction(
db: AsyncSession,
*,
payment: OverpayPayment,
transaction_id: int,
) -> OverpayPayment:
"""Связывает платеж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+23 -1
View File
@@ -33,7 +33,15 @@ logger = structlog.get_logger(__name__)
async def _get_default_promo_group_id(db: AsyncSession) -> int | None:
result = await db.execute(select(PromoGroup.id).where(PromoGroup.is_default.is_(True)).limit(1))
return result.scalar_one_or_none()
default_id = result.scalar_one_or_none()
if default_id is not None:
return default_id
# На пустой БД дефолтной промогруппы нет — создаём автоматически
from app.database.crud.user import _get_or_create_default_promo_group
default_group = await _get_or_create_default_promo_group(db)
return default_group.id
async def create_server_squad(
@@ -155,6 +163,20 @@ async def get_available_server_squads(
return result.scalars().unique().all()
async def get_effective_tariff_squad_uuids(
db: AsyncSession,
allowed_squads: Sequence[str] | None,
) -> list[str]:
"""Resolve tariff squads, treating an empty list as "all available squads"."""
normalized = [str(squad_uuid) for squad_uuid in (allowed_squads or []) if squad_uuid]
if normalized:
return list(dict.fromkeys(normalized))
available = await get_available_server_squads(db)
return [squad.squad_uuid for squad in available if squad.squad_uuid]
async def get_active_server_squads(db: AsyncSession) -> list[ServerSquad]:
"""Возвращает список активных серверов, доступных для подключения."""
+10 -8
View File
@@ -1,3 +1,4 @@
import math
import secrets
from collections.abc import Iterable
from datetime import UTC, datetime, timedelta
@@ -142,8 +143,8 @@ async def create_trial_subscription(
if device_limit is None:
device_limit = settings.TRIAL_DEVICE_LIMIT
# Если переданы connected_squads, используем их
# Иначе используем squad_uuid или получаем случайный
# Если переданы connected_squads, используем их.
# Иначе используем squad_uuid или все доступные сквады по умолчанию.
final_squads = []
if connected_squads:
final_squads = connected_squads
@@ -151,13 +152,14 @@ async def create_trial_subscription(
final_squads = [squad_uuid]
else:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
random_squad = await get_random_trial_squad_uuid(db)
if random_squad:
final_squads = [random_squad]
final_squads = await get_effective_tariff_squad_uuids(db, None)
if final_squads:
logger.debug(
'Выбран сквад для триальной подписки пользователя', random_squad=random_squad, user_id=user_id
'Выбраны дефолтные сквады для триальной подписки пользователя',
final_squads=final_squads,
user_id=user_id,
)
except Exception as error:
logger.error('Не удалось получить сквад для триальной подписки пользователя', user_id=user_id, error=error)
@@ -1295,7 +1297,7 @@ async def add_subscription_servers(
if paid_prices is None:
now = datetime.now(UTC)
days_remaining = max(1, (subscription.end_date - now).days)
days_remaining = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
paid_prices = []
from app.database.models import ServerSquad
+6 -2
View File
@@ -25,6 +25,7 @@ class TicketCRUD:
media_type: str | None = None,
media_file_id: str | None = None,
media_caption: str | None = None,
media_items: list[dict] | None = None,
) -> Ticket:
"""Создать новый тикет с первым сообщением"""
ticket = Ticket(user_id=user_id, title=title, status=TicketStatus.OPEN.value, priority=priority)
@@ -37,10 +38,11 @@ class TicketCRUD:
user_id=user_id,
message_text=message_text,
is_from_admin=False,
has_media=bool(media_type and media_file_id),
has_media=bool(media_type and media_file_id) or bool(media_items),
media_type=media_type,
media_file_id=media_file_id,
media_caption=media_caption,
media_items=media_items,
)
db.add(message)
@@ -381,6 +383,7 @@ class TicketMessageCRUD:
media_type: str | None = None,
media_file_id: str | None = None,
media_caption: str | None = None,
media_items: list[dict] | None = None,
) -> TicketMessage:
"""Добавить сообщение в тикет"""
message = TicketMessage(
@@ -388,10 +391,11 @@ class TicketMessageCRUD:
user_id=user_id,
message_text=message_text,
is_from_admin=is_from_admin,
has_media=bool(media_type and media_file_id),
has_media=bool(media_type and media_file_id) or bool(media_items),
media_type=media_type,
media_file_id=media_file_id,
media_caption=media_caption,
media_items=media_items,
)
db.add(message)
+12 -6
View File
@@ -27,6 +27,12 @@ REAL_PAYMENT_METHODS = [
PaymentMethod.KASSA_AI.value,
PaymentMethod.RIOPAY.value,
PaymentMethod.SEVERPAY.value,
PaymentMethod.ROLLYPAY.value,
PaymentMethod.PAYPEAR.value,
PaymentMethod.OVERPAY.value,
PaymentMethod.AURAPAY.value,
PaymentMethod.ETOPLATEZHI.value,
PaymentMethod.ANTILOPAY.value,
]
@@ -230,17 +236,17 @@ async def get_user_transactions_count(
async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
"""Sum of personal spending for promo group auto-assignment.
Only counts SUBSCRIPTION_PAYMENT (user's own subscriptions).
GIFT_PAYMENT is excluded buying a gift for someone else is not personal spending.
"""
result = await db.execute(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.user_id == user_id,
Transaction.is_completed.is_(True),
Transaction.type.in_(
[
TransactionType.SUBSCRIPTION_PAYMENT.value,
TransactionType.GIFT_PAYMENT.value,
]
),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
)
)
)
+144 -26
View File
@@ -3,7 +3,7 @@ import string
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import and_, case, func, nullslast, or_, select, text
from sqlalchemy import and_, case, exists, func, nullslast, or_, select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -13,6 +13,8 @@ from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
from app.database.crud.promo_group import get_default_promo_group
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.database.models import (
AdvertisingCampaign,
AdvertisingCampaignRegistration,
PaymentMethod,
PromoGroup,
Subscription,
@@ -710,30 +712,52 @@ async def subtract_user_balance(
await db.refresh(user)
if consume_promo_offer and log_context:
try:
await log_promo_offer_action(
db,
user_id=user.id,
offer_id=log_context.get('offer_id'),
action='consumed',
source=log_context.get('source'),
percent=log_context.get('percent'),
effect_type=log_context.get('effect_type'),
details=log_context.get('details'),
commit=commit,
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to record promo offer consumption log for user', user_id=user.id, log_error=log_error
)
if commit:
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to rollback session after promo offer consumption log failure',
rollback_error=rollback_error,
# Пишем лог в ОТДЕЛЬНОЙ сессии, чтобы его commit/rollback не касался
# основной сессии caller'а. Иначе rollback в случае фейла логирования
# экспайрит объекты сессии и следующее обращение к subscription/user
# attrs у caller'а падает с MissingGreenlet.
if commit:
try:
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as log_db:
await log_promo_offer_action(
log_db,
user_id=user.id,
offer_id=log_context.get('offer_id'),
action='consumed',
source=log_context.get('source'),
percent=log_context.get('percent'),
effect_type=log_context.get('effect_type'),
details=log_context.get('details'),
commit=True,
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to record promo offer consumption log for user',
user_id=user.id,
log_error=log_error,
)
else:
# Caller управляет транзакцией — пишем в его сессию без commit.
try:
await log_promo_offer_action(
db,
user_id=user.id,
offer_id=log_context.get('offer_id'),
action='consumed',
source=log_context.get('source'),
percent=log_context.get('percent'),
effect_type=log_context.get('effect_type'),
details=log_context.get('details'),
commit=False,
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to record promo offer consumption log for user',
user_id=user.id,
log_error=log_error,
)
logger.info('✅ Средства списаны: →', old_balance=old_balance, balance_kopeks=user.balance_kopeks)
return True
@@ -839,6 +863,11 @@ async def get_users_list(
search: str | None = None,
email: str | None = None,
status: UserStatus | None = None,
subscription_status: str | None = None,
tariff_ids: list[int] | None = None,
promo_group_id: int | None = None,
campaign_id: int | None = None,
partner_id: int | None = None,
order_by_balance: bool = False,
order_by_traffic: bool = False,
order_by_last_activity: bool = False,
@@ -854,6 +883,41 @@ async def get_users_list(
if status:
query = query.where(User.status == status.value)
# Subscription-level filters via subquery
if subscription_status or tariff_ids:
sub_conditions = []
if subscription_status:
sub_conditions.append(Subscription.status == subscription_status)
if tariff_ids:
sub_conditions.append(Subscription.tariff_id.in_(tariff_ids))
sub_query = select(Subscription.user_id).where(and_(*sub_conditions)).distinct().scalar_subquery()
query = query.where(User.id.in_(sub_query))
if promo_group_id:
query = query.where(User.promo_group_id == promo_group_id)
if campaign_id:
query = query.where(
exists(
select(AdvertisingCampaignRegistration.id).where(
AdvertisingCampaignRegistration.user_id == User.id,
AdvertisingCampaignRegistration.campaign_id == campaign_id,
)
)
)
if partner_id:
query = query.where(
exists(
select(AdvertisingCampaignRegistration.id)
.join(AdvertisingCampaign, AdvertisingCampaign.id == AdvertisingCampaignRegistration.campaign_id)
.where(
AdvertisingCampaignRegistration.user_id == User.id,
AdvertisingCampaign.partner_user_id == partner_id,
)
)
)
if search:
search_term = f'%{search}%'
conditions = [
@@ -933,13 +997,55 @@ async def get_users_list(
async def get_users_count(
db: AsyncSession, status: UserStatus | None = None, search: str | None = None, email: str | None = None
db: AsyncSession,
status: UserStatus | None = None,
search: str | None = None,
email: str | None = None,
subscription_status: str | None = None,
tariff_ids: list[int] | None = None,
promo_group_id: int | None = None,
campaign_id: int | None = None,
partner_id: int | None = None,
) -> int:
query = select(func.count(User.id))
if status:
query = query.where(User.status == status.value)
if subscription_status or tariff_ids:
sub_conditions = []
if subscription_status:
sub_conditions.append(Subscription.status == subscription_status)
if tariff_ids:
sub_conditions.append(Subscription.tariff_id.in_(tariff_ids))
sub_query = select(Subscription.user_id).where(and_(*sub_conditions)).distinct().scalar_subquery()
query = query.where(User.id.in_(sub_query))
if promo_group_id:
query = query.where(User.promo_group_id == promo_group_id)
if campaign_id:
query = query.where(
exists(
select(AdvertisingCampaignRegistration.id).where(
AdvertisingCampaignRegistration.user_id == User.id,
AdvertisingCampaignRegistration.campaign_id == campaign_id,
)
)
)
if partner_id:
query = query.where(
exists(
select(AdvertisingCampaignRegistration.id)
.join(AdvertisingCampaign, AdvertisingCampaign.id == AdvertisingCampaignRegistration.campaign_id)
.where(
AdvertisingCampaignRegistration.user_id == User.id,
AdvertisingCampaign.partner_user_id == partner_id,
)
)
)
if search:
search_term = f'%{search}%'
conditions = [
@@ -1090,6 +1196,12 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> list[Us
async def get_inactive_users(db: AsyncSession, months: int = 3) -> list[User]:
threshold_date = datetime.now(UTC) - timedelta(days=months * 30)
# Подзапрос: пользователи, у которых есть подписка с end_date >= threshold
# (активная или недавно истёкшая) — таких удалять нельзя
users_with_recent_subs = (
select(Subscription.user_id).where(Subscription.end_date >= threshold_date).distinct().scalar_subquery()
)
result = await db.execute(
select(User)
.options(
@@ -1098,7 +1210,13 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> list[User]:
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(and_(User.last_activity < threshold_date, User.status == UserStatus.ACTIVE.value))
.where(
and_(
User.last_activity < threshold_date,
User.status == UserStatus.ACTIVE.value,
User.id.not_in(users_with_recent_subs),
)
)
)
users = result.scalars().all()
+108
View File
@@ -0,0 +1,108 @@
"""CRUD operations for yandex_client_id_map table."""
from __future__ import annotations
from datetime import UTC, datetime
import structlog
from sqlalchemy import select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import YandexClientIdMap
logger = structlog.get_logger(__name__)
async def upsert_cid(
db: AsyncSession,
user_id: int,
cid: str,
source: str = 'web',
counter_id: str | None = None,
subid: str | None = None,
) -> YandexClientIdMap:
"""Insert or update Yandex ClientID for a user (race-safe via ON CONFLICT)."""
now = datetime.now(UTC)
values = {
'yandex_cid': cid,
'source': source,
'updated_at': now,
}
if counter_id:
values['counter_id'] = counter_id
if subid:
values['subid'] = subid
stmt = (
pg_insert(YandexClientIdMap)
.values(user_id=user_id, yandex_cid=cid, source=source, counter_id=counter_id, subid=subid)
.on_conflict_do_update(index_elements=['user_id'], set_=values)
.returning(YandexClientIdMap)
)
result = await db.execute(stmt)
await db.flush()
return result.scalar_one()
async def get_cid(db: AsyncSession, user_id: int) -> YandexClientIdMap | None:
"""Get Yandex ClientID mapping for a user."""
result = await db.execute(select(YandexClientIdMap).where(YandexClientIdMap.user_id == user_id))
return result.scalar_one_or_none()
async def mark_registration_sent(db: AsyncSession, user_id: int) -> None:
"""Mark registration event as sent for a user."""
await db.execute(
update(YandexClientIdMap)
.where(YandexClientIdMap.user_id == user_id)
.values(registration_sent=True, updated_at=datetime.now(UTC))
)
await db.flush()
async def mark_trial_sent(db: AsyncSession, user_id: int) -> None:
"""Mark trial event as sent for a user."""
await db.execute(
update(YandexClientIdMap)
.where(YandexClientIdMap.user_id == user_id)
.values(trial_sent=True, updated_at=datetime.now(UTC))
)
await db.flush()
async def upsert_subid(
db: AsyncSession,
user_id: int,
subid: str,
source: str = 'web',
) -> None:
"""Save subid for a user. Updates existing record or creates with placeholder CID."""
if not subid or len(subid) > 255:
return
now = datetime.now(UTC)
# Try update first (don't create empty CID records)
result = await db.execute(
update(YandexClientIdMap).where(YandexClientIdMap.user_id == user_id).values(subid=subid, updated_at=now)
)
if result.rowcount == 0:
# No existing record — create with placeholder
stmt = (
pg_insert(YandexClientIdMap)
.values(user_id=user_id, yandex_cid='_subid_only', source=source, subid=subid)
.on_conflict_do_update(
index_elements=['user_id'],
set_={'subid': subid, 'updated_at': now},
)
)
await db.execute(stmt)
await db.flush()
logger.info('Subid saved', user_id=user_id, subid=subid, source=source)
async def get_subid(db: AsyncSession, user_id: int) -> str | None:
"""Get subid for a user."""
result = await db.execute(select(YandexClientIdMap.subid).where(YandexClientIdMap.user_id == user_id))
return result.scalar_one_or_none()
+533
View File
@@ -162,8 +162,16 @@ class PaymentMethod(Enum):
KASSA_AI = 'kassa_ai'
RIOPAY = 'riopay'
SEVERPAY = 'severpay'
APPLE_IAP = 'apple_iap'
PAYPEAR = 'paypear'
ROLLYPAY = 'rollypay'
OVERPAY = 'overpay'
AURAPAY = 'aurapay'
ETOPLATEZHI = 'etoplatezhi'
ANTILOPAY = 'antilopay'
JUPITER = 'jupiter'
DONUT = 'donut'
LAVA = 'lava'
MANUAL = 'manual'
BALANCE = 'balance'
@@ -327,6 +335,41 @@ class CryptoBotPayment(Base):
return f'<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>'
class AppleTransaction(Base):
__tablename__ = 'apple_transactions'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
transaction_id = Column(String(64), unique=True, nullable=False, index=True)
original_transaction_id = Column(String(64), nullable=True, index=True)
product_id = Column(String(128), nullable=False)
bundle_id = Column(String(255), nullable=False)
amount_kopeks = Column(Integer, nullable=False)
environment = Column(String(16), nullable=False)
status = Column(String(50), default='verified')
is_paid = Column(Boolean, default=True)
paid_at = Column(AwareDateTime(), nullable=True)
refunded_at = Column(AwareDateTime(), nullable=True)
transaction_id_fk = Column(Integer, ForeignKey('transactions.id'), nullable=True)
metadata_json = Column(JSON, nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
user = relationship('User', backref='apple_transactions')
transaction = relationship('Transaction', backref='apple_transaction')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
def __repr__(self):
return f'<AppleTransaction(id={self.id}, txn={self.transaction_id}, product={self.product_id}, status={self.status})>'
class HeleketPayment(Base):
__tablename__ = 'heleket_payments'
@@ -1004,6 +1047,442 @@ class RollyPayPayment(Base):
return f'<RollyPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class OverpayPayment(Base):
"""Платежи через Overpay (pay.overpay.io)."""
__tablename__ = 'overpay_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
overpay_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Overpay
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_method = Column(String(32), nullable=True)
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='overpay_payments')
transaction = relationship('Transaction', backref='overpay_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == 'pending'
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'canceled', 'chargeback', 'amount_mismatch']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<OverpayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class AuraPayPayment(Base):
"""Платежи через AuraPay (aurapay.tech)."""
__tablename__ = 'aurapay_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
aurapay_invoice_id = Column(String(128), unique=True, nullable=True, index=True) # UUID от AuraPay
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_method = Column(String(32), nullable=True)
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='aurapay_payments')
transaction = relationship('Transaction', backref='aurapay_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == 'pending'
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'canceled', 'amount_mismatch']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<AuraPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class EtoplatezhiPayment(Base):
"""Платежи через Etoplatezhi (paymentpage.etoplatezhi.ru)."""
__tablename__ = 'etoplatezhi_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
etoplatezhi_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Etoplatezhi
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_method = Column(String(32), nullable=True)
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='etoplatezhi_payments')
transaction = relationship('Transaction', backref='etoplatezhi_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == 'pending'
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'canceled', 'amount_mismatch']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<EtoplatezhiPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class AntilopayPayment(Base):
"""Платежи через Antilopay (lk.antilopay.com)."""
__tablename__ = 'antilopay_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
antilopay_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Antilopay (APAY...)
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_method = Column(String(32), nullable=True)
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='antilopay_payments')
transaction = relationship('Transaction', backref='antilopay_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == 'pending'
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'canceled', 'amount_mismatch']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<AntilopayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class JupiterPayment(Base):
"""Платежи через Jupiter (FPGate P2P v2.1, app.juppiter.tech)."""
__tablename__ = 'jupiter_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
jupiter_transaction_id = Column(String(128), unique=True, nullable=True, index=True) # transaction_id от Jupiter
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True) # qrcode_url из details (если есть)
payment_method = Column(String(32), nullable=True) # 'sbp' и т.д.
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='jupiter_payments')
transaction = relationship('Transaction', backref='jupiter_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == 'pending'
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'cancelled', 'amount_mismatch', 'declined', 'error']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<JupiterPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class DonutPayment(Base):
"""Платежи через Donut P2P (gw.donut.business)."""
__tablename__ = 'donut_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
donut_transaction_id = Column(String(128), unique=True, nullable=True, index=True) # transaction_id от Donut
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True) # redirect_url или qrcode_url
payment_method = Column(String(32), nullable=True) # 'card', 'sbp', 'sbp_qr'
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='donut_payments')
transaction = relationship('Transaction', backref='donut_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status in ('pending', 'created', 'processing')
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'cancelled', 'amount_mismatch', 'error']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<DonutPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class LavaPayment(Base):
"""Платежи через Lava Business (gate.lava.ru)."""
__tablename__ = 'lava_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш orderId
lava_invoice_id = Column(String(128), unique=True, nullable=True, index=True) # invoice_id (UUID) от Lava
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_method = Column(String(32), nullable=True) # 'card', 'sbp' и т.д.
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='lava_payments')
transaction = relationship('Transaction', backref='lava_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status in ('pending', 'created', 'processing')
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'cancel', 'cancelled', 'amount_mismatch', 'error']
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
f'<LavaPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
)
class PromoGroup(Base):
__tablename__ = 'promo_groups'
@@ -1520,6 +1999,7 @@ class Subscription(Base):
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
last_webhook_update_at = Column(AwareDateTime(), nullable=True)
last_revoke_at = Column(AwareDateTime(), nullable=True)
remnawave_short_uuid = Column(String(255), nullable=True)
remnawave_uuid = Column(String(255), nullable=True)
@@ -2773,6 +3253,8 @@ class TicketMessage(Base):
media_type = Column(String(20), nullable=True) # photo, video, document, voice, etc.
media_file_id = Column(String(255), nullable=True)
media_caption = Column(Text, nullable=True)
# Multi-media gallery (photos/videos/documents bundled in one bubble)
media_items = Column(JSONB, nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
@@ -3394,6 +3876,13 @@ class LandingPage(Base):
background_config = Column(
JSON, nullable=True
) # AnimationConfig: {enabled, type, settings, opacity, blur, reducedOnMobile}
# Sticky pay button on mobile (full-width fixed bottom)
sticky_pay_button = Column(Boolean, nullable=False, default=False, server_default=text('false'))
# Yandex Metrika landing-level conversion goals
analytics_view_enabled = Column(Boolean, nullable=False, default=False, server_default=text('false'))
analytics_view_goal = Column(String(64), nullable=True)
analytics_click_enabled = Column(Boolean, nullable=False, default=False, server_default=text('false'))
analytics_click_goal = Column(String(64), nullable=True)
created_at = Column(AwareDateTime(), server_default=func.now())
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
@@ -3456,6 +3945,10 @@ class GuestPurchase(Base):
retry_count = Column(Integer, nullable=False, default=0, server_default='0')
receipt_uuid = Column(String(255), nullable=True, index=True)
receipt_created_at = Column(AwareDateTime(), nullable=True)
# Yandex Metrika offline conversions: client identifier + traffic source tags
yandex_cid = Column(String(128), nullable=True)
subid = Column(String(255), nullable=True)
referrer = Column(String(500), nullable=True)
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
tariff = relationship('Tariff', lazy='selectin')
@@ -3537,3 +4030,43 @@ class NewsTag(Base):
def __repr__(self) -> str:
return f"<NewsTag id={self.id} name='{self.name}'>"
class YandexClientIdMap(Base):
"""Yandex Metrika client identifier captured per user.
Stores the mapping user_id -> yandex_cid so we can fire offline
conversion events to mc.yandex.ru with the right CID even after
the user leaves the landing/web flow. The ``subid`` column carries
a pass-through traffic-source identifier for S2S postbacks.
"""
__tablename__ = 'yandex_client_id_map'
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), unique=True, nullable=False)
yandex_cid = Column(String(128), nullable=False)
source = Column(String(20), nullable=False, default='web', server_default='web')
counter_id = Column(String(32), nullable=True)
registration_sent = Column(Boolean, default=False, server_default=text('false'), nullable=False)
trial_sent = Column(Boolean, default=False, server_default=text('false'), nullable=False)
subid = Column(String(255), nullable=True)
created_at = Column(AwareDateTime(), server_default=func.now())
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
class InfoPage(Base):
"""Static informational page with multilingual title/content (JSONB)."""
__tablename__ = 'info_pages'
id = Column(Integer, primary_key=True, index=True)
slug = Column(String(200), unique=True, nullable=False)
title = Column(JSONB, nullable=False, server_default='{}')
content = Column(JSONB, nullable=False, server_default='{}')
page_type = Column(String(20), nullable=False, default='page', server_default='page')
is_active = Column(Boolean, nullable=False, default=True, server_default='true')
sort_order = Column(Integer, nullable=False, default=0, server_default='0')
icon = Column(String(50), nullable=True)
replaces_tab = Column(String(20), nullable=True) # 'faq', 'rules', 'privacy', 'offer', or null
created_at = Column(AwareDateTime(), server_default=func.now())
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
+411
View File
@@ -0,0 +1,411 @@
"""Apple App Store Server API client for In-App Purchase verification and webhook handling."""
from __future__ import annotations
import base64
import datetime
import json
import time
from typing import Any
import httpx
import jwt as pyjwt
import structlog
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ec, utils as asym_utils
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.x509 import load_der_x509_certificate
from cryptography.x509.oid import ExtensionOID, ObjectIdentifier
from app.config import settings
logger = structlog.get_logger(__name__)
# Apple Root CA - G3 SHA-256 fingerprint for chain pinning
# https://www.apple.com/certificateauthority/
APPLE_ROOT_CA_G3_SHA256 = bytes.fromhex('63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179')
# Apple WWDR Intermediate Certificate OID
APPLE_WWDR_INTERMEDIATE_OID = ObjectIdentifier('1.2.840.113635.100.6.2.1')
PRODUCTION_BASE_URL = 'https://api.storekit.itunes.apple.com'
SANDBOX_BASE_URL = 'https://api.storekit-sandbox.itunes.apple.com'
class AppleIAPService:
"""Service for verifying Apple In-App Purchase transactions and handling notifications."""
def _get_base_url(self, environment: str | None = None) -> str:
env = environment or settings.APPLE_IAP_ENVIRONMENT
if env == 'Sandbox':
return SANDBOX_BASE_URL
return PRODUCTION_BASE_URL
def _generate_jwt(self) -> str:
"""Generate a fresh ES256 JWT for App Store Server API authentication.
Apple recommends generating a new JWT for each request.
"""
private_key = settings.get_apple_iap_private_key()
if not private_key:
raise ValueError('Apple IAP private key is not configured')
now = int(time.time())
payload = {
'iss': settings.APPLE_IAP_ISSUER_ID,
'iat': now,
'exp': now + 3600,
'aud': 'appstoreconnect-v1',
'bid': settings.APPLE_IAP_BUNDLE_ID,
}
headers = {
'alg': 'ES256',
'kid': settings.APPLE_IAP_KEY_ID,
'typ': 'JWT',
}
return pyjwt.encode(payload, private_key, algorithm='ES256', headers=headers)
async def _fetch_transaction(
self,
transaction_id: str,
base_url: str,
) -> httpx.Response | None:
"""Send a GET request to Apple's transaction lookup endpoint."""
url = f'{base_url}/inApps/v1/transactions/{transaction_id}'
token = self._generate_jwt()
async with httpx.AsyncClient(timeout=30) as client:
try:
return await client.get(
url,
headers={'Authorization': f'Bearer {token}'},
)
except httpx.RequestError as e:
logger.error('Apple API request failed', error=str(e), transaction_id=transaction_id)
return None
async def verify_transaction(
self,
transaction_id: str,
environment: str | None = None,
) -> dict[str, Any] | None:
"""Verify a transaction with Apple's App Store Server API.
Follows Apple's recommendation: if the configured environment returns
a 4xx error, retries against the opposite environment. This ensures
Sandbox purchases made during App Review still verify when the server
is configured for Production.
"""
primary_url = self._get_base_url(environment)
# Determine fallback URL (opposite environment)
fallback_url = SANDBOX_BASE_URL if primary_url == PRODUCTION_BASE_URL else PRODUCTION_BASE_URL
for attempt_url in (primary_url, fallback_url):
response = await self._fetch_transaction(transaction_id, attempt_url)
if response is None:
return None # network error -- don't retry
if response.status_code == 200:
return self._parse_transaction_response(response, transaction_id)
# 4xx on primary -> retry on fallback per Apple docs
if 400 <= response.status_code < 500 and attempt_url == primary_url:
logger.info(
'Apple API returned 4xx on primary env, retrying fallback',
status=response.status_code,
primary=attempt_url,
fallback=fallback_url,
transaction_id=transaction_id,
)
continue
# Log the final failure
self._log_api_error(response, transaction_id)
return None
return None
def _parse_transaction_response(
self,
response: httpx.Response,
transaction_id: str,
) -> dict[str, Any] | None:
"""Extract and verify signedTransactionInfo from a 200 response."""
data = response.json()
signed_transaction_info = data.get('signedTransactionInfo')
if signed_transaction_info:
decoded = self._verify_and_decode_jws(signed_transaction_info)
if decoded:
return decoded
logger.warning('Failed to verify signedTransactionInfo', transaction_id=transaction_id)
return None
logger.warning('No signedTransactionInfo in response', transaction_id=transaction_id)
return None
@staticmethod
def _log_api_error(response: httpx.Response, transaction_id: str) -> None:
if response.status_code == 404:
logger.warning('Apple transaction not found', transaction_id=transaction_id)
elif response.status_code == 401:
logger.error('Apple API auth failed -- check key configuration')
elif response.status_code == 429:
logger.warning('Apple API rate limit exceeded')
else:
logger.error(
'Apple API unexpected status',
status=response.status_code,
body=response.text[:500],
transaction_id=transaction_id,
)
def validate_transaction_info(self, txn_info: dict[str, Any], expected_product_id: str) -> str | None:
"""Validate decoded transaction info fields.
Returns None if valid, or an error message string.
"""
bundle_id = txn_info.get('bundleId')
if bundle_id != settings.APPLE_IAP_BUNDLE_ID:
return f'Bundle ID mismatch: {bundle_id}'
product_id = txn_info.get('productId')
if product_id != expected_product_id:
return f'Product ID mismatch: {product_id} != {expected_product_id}'
txn_type = txn_info.get('type')
if txn_type != 'Consumable':
return f'Unexpected transaction type: {txn_type}'
if txn_info.get('revocationDate'):
return f'Transaction was revoked at {txn_info["revocationDate"]}'
return None
def _verify_and_decode_jws(self, jws_token: str) -> dict[str, Any] | None:
"""Verify x5c certificate chain and ES256 signature, then decode the JWS payload.
Returns the decoded payload dict, or None if verification fails.
Used for both outer notification payloads and inner signed data
(signedTransactionInfo, signedRenewalInfo).
"""
try:
parts = jws_token.split('.')
if len(parts) != 3:
logger.warning('Invalid JWS format: expected 3 parts')
return None
# Decode header to get x5c chain
header_b64 = parts[0]
padding = 4 - len(header_b64) % 4
if padding != 4:
header_b64 += '=' * padding
header_json = base64.urlsafe_b64decode(header_b64)
header = json.loads(header_json)
x5c_chain = header.get('x5c', [])
if not x5c_chain:
logger.warning('No x5c certificate chain in JWS header')
return None
# Verify the certificate chain
if not self._verify_x5c_chain(x5c_chain):
logger.warning('x5c certificate chain verification failed')
return None
# Verify the signature using the leaf certificate
leaf_cert_der = base64.b64decode(x5c_chain[0])
leaf_cert = load_der_x509_certificate(leaf_cert_der)
public_key = leaf_cert.public_key()
signing_input = f'{parts[0]}.{parts[1]}'.encode('ascii')
signature_b64 = parts[2]
sig_padding = 4 - len(signature_b64) % 4
if sig_padding != 4:
signature_b64 += '=' * sig_padding
signature = base64.urlsafe_b64decode(signature_b64)
# ES256 signatures from JWS are in raw (r||s) format, convert to DER
if len(signature) == 64:
r = int.from_bytes(signature[:32], 'big')
s = int.from_bytes(signature[32:], 'big')
signature = asym_utils.encode_dss_signature(r, s)
public_key.verify(signature, signing_input, ec.ECDSA(SHA256()))
# Signature valid -- decode payload
return self._decode_jws_payload(jws_token)
except Exception as e:
logger.error('JWS verification failed', error=str(e), exc_info=True)
return None
def verify_notification(self, signed_payload: str) -> dict[str, Any] | None:
"""Verify and decode an App Store Server Notification V2 payload.
Verifies the JWS x5c certificate chain, then returns the decoded payload.
Returns None if verification fails.
"""
return self._verify_and_decode_jws(signed_payload)
def _verify_x5c_chain(self, x5c_chain: list[str]) -> bool:
"""Verify the x5c certificate chain ends with an Apple Root CA."""
try:
if len(x5c_chain) < 2:
logger.warning('x5c chain too short', length=len(x5c_chain))
return False
certs = []
for cert_b64 in x5c_chain:
cert_der = base64.b64decode(cert_b64)
cert = load_der_x509_certificate(cert_der)
certs.append(cert)
# Check certificate validity periods
now = datetime.datetime.now(datetime.UTC)
for i, cert in enumerate(certs):
if now < cert.not_valid_before_utc:
logger.warning('x5c cert not yet valid', index=i, not_before=str(cert.not_valid_before_utc))
return False
if now > cert.not_valid_after_utc:
logger.warning('x5c cert expired', index=i, not_after=str(cert.not_valid_after_utc))
return False
# Pin the root (last) certificate by SHA-256 fingerprint
root_cert = certs[-1]
root_fingerprint = root_cert.fingerprint(SHA256())
if root_fingerprint != APPLE_ROOT_CA_G3_SHA256:
logger.warning(
'Root CA fingerprint mismatch -- not genuine Apple Root CA - G3',
got=root_fingerprint.hex(),
)
return False
# Verify each certificate is signed by the next one in the chain
for i in range(len(certs) - 1):
child = certs[i]
parent = certs[i + 1]
parent_public_key = parent.public_key()
parent_public_key.verify(
child.signature,
child.tbs_certificate_bytes,
ec.ECDSA(child.signature_hash_algorithm),
)
# FIX 3: Validate Apple WWDR intermediate OID
# The intermediate cert (index 1) must contain the Apple WWDR OID
# to ensure it is a genuine Apple WWDR intermediate certificate.
if len(certs) >= 2:
intermediate_cert = certs[1]
try:
# Check for the Apple WWDR OID in certificate extensions
found_apple_oid = False
for ext in intermediate_cert.extensions:
if ext.oid == ExtensionOID.CERTIFICATE_POLICIES:
for policy in ext.value:
if policy.policy_identifier == APPLE_WWDR_INTERMEDIATE_OID:
found_apple_oid = True
break
if found_apple_oid:
break
if not found_apple_oid:
logger.warning(
'Intermediate cert missing Apple WWDR OID',
oid=str(APPLE_WWDR_INTERMEDIATE_OID),
)
return False
except x509.ExtensionNotFound:
logger.warning('Intermediate cert has no certificate policies extension')
return False
return True
except Exception as e:
logger.error('x5c chain verification error', error=str(e))
return False
def _decode_jws_payload(self, jws_token: str) -> dict[str, Any] | None:
"""Decode the payload from a JWS token without signature verification.
Use only after the signature has already been verified.
"""
try:
parts = jws_token.split('.')
if len(parts) != 3:
return None
payload_b64 = parts[1]
# Add base64url padding
padding = 4 - len(payload_b64) % 4
if padding != 4:
payload_b64 += '=' * padding
payload_json = base64.urlsafe_b64decode(payload_b64)
return json.loads(payload_json)
except Exception as e:
logger.error('Failed to decode JWS payload', error=str(e))
return None
async def send_consumption_info(
self,
transaction_id: str,
customer_consented: bool,
consumption_status: int = 0,
delivery_status: int = 0,
lifetime_dollars_purchased: int = 0,
lifetime_dollars_refunded: int = 0,
platform: int = 1,
play_time: int = 0,
sample_content_provided: bool = False,
user_status: int = 0,
environment: str | None = None,
refund_preference: int | None = None,
) -> bool:
"""Send consumption information to Apple in response to CONSUMPTION_REQUEST.
Must be sent within 12 hours of receiving the notification.
"""
base_url = self._get_base_url(environment)
url = f'{base_url}/inApps/v2/transactions/consumption/{transaction_id}'
token = self._generate_jwt()
body: dict[str, Any] = {
'customerConsented': customer_consented,
'consumptionStatus': consumption_status,
'deliveryStatus': delivery_status,
'lifetimeDollarsPurchased': lifetime_dollars_purchased,
'lifetimeDollarsRefunded': lifetime_dollars_refunded,
'platform': platform,
'playTime': play_time,
'sampleContentProvided': sample_content_provided,
'userStatus': user_status,
}
if refund_preference is not None:
body['refundPreference'] = refund_preference
async with httpx.AsyncClient(timeout=30) as client:
try:
response = await client.put(
url,
json=body,
headers={
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json',
},
)
except httpx.RequestError as e:
logger.error('Apple consumption API request failed', error=str(e))
return False
if response.status_code == 202:
logger.info('Consumption info sent to Apple', transaction_id=transaction_id)
return True
logger.error(
'Apple consumption API error',
status=response.status_code,
body=response.text[:500],
transaction_id=transaction_id,
)
return False
+21
View File
@@ -541,6 +541,27 @@ class RemnaWaveAPI:
return []
raise
async def get_subscription_request_history(
self,
uuid: str,
offset: int = 0,
limit: int = 20,
) -> dict:
"""Get subscription request history for a panel user.
Returns dict with 'total' and 'records' list.
Each record has: id, userUuid, requestAt, requestIp, userAgent.
"""
try:
response = await self._make_request(
'GET',
f'/api/users/{uuid}/subscription-request-history',
params={'offset': offset, 'limit': limit},
)
return response.get('response', {'total': 0, 'records': []})
except RemnaWaveAPIError:
return {'total': 0, 'records': []}
async def update_user(
self,
uuid: str,
+339
View File
@@ -57,6 +57,9 @@ class WebhookServer:
self.app.router.add_get('/health', self._health_check)
if settings.is_apple_iap_enabled():
self.app.router.add_post(settings.APPLE_IAP_WEBHOOK_PATH, self._apple_iap_webhook_handler)
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
if settings.is_mulenpay_enabled():
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
@@ -64,6 +67,8 @@ class WebhookServer:
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
if settings.is_freekassa_enabled():
self.app.router.add_options(settings.FREEKASSA_WEBHOOK_PATH, self._options_handler)
if settings.is_apple_iap_enabled():
self.app.router.add_options(settings.APPLE_IAP_WEBHOOK_PATH, self._options_handler)
logger.info('Webhook сервер настроен:')
logger.info('Tribute webhook: POST', TRIBUTE_WEBHOOK_PATH=settings.TRIBUTE_WEBHOOK_PATH)
@@ -76,6 +81,8 @@ class WebhookServer:
logger.info('CryptoBot webhook: POST', CRYPTOBOT_WEBHOOK_PATH=settings.CRYPTOBOT_WEBHOOK_PATH)
if settings.is_freekassa_enabled():
logger.info('Freekassa webhook: POST', FREEKASSA_WEBHOOK_PATH=settings.FREEKASSA_WEBHOOK_PATH)
if settings.is_apple_iap_enabled():
logger.info('Apple IAP webhook: POST', APPLE_IAP_WEBHOOK_PATH=settings.APPLE_IAP_WEBHOOK_PATH)
logger.info(' - Health check: GET /health')
return self.app
@@ -491,3 +498,335 @@ class WebhookServer:
except Exception as e:
logger.error('Критическая ошибка обработки Freekassa webhook', error=e, exc_info=True)
return web.Response(text='NO', status=500)
async def _apple_iap_webhook_handler(self, request: web.Request) -> web.Response:
"""Handle Apple App Store Server Notifications V2."""
try:
logger.info('Получен Apple IAP webhook', method=request.method, path=request.path)
raw_body = await request.read()
if not raw_body:
logger.warning('Пустой Apple IAP webhook')
return web.Response(status=400)
try:
body = json.loads(raw_body.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
logger.error('Ошибка парсинга Apple IAP webhook', error=e)
return web.Response(status=400)
signed_payload = body.get('signedPayload')
if not signed_payload:
logger.warning('No signedPayload in Apple webhook')
return web.Response(status=400)
# Verify and decode the notification
from app.external.apple_iap import AppleIAPService
apple_service = AppleIAPService()
notification = apple_service.verify_notification(signed_payload)
if not notification:
logger.warning('Apple webhook signature verification failed')
return web.Response(status=403)
notification_type = notification.get('notificationType', '')
subtype = notification.get('subtype', '')
# Verify notification environment matches our config
# FIX 11: removed dead initial assignment of expected_envs
notif_env = notification.get('data', {}).get('environment', '')
if settings.APPLE_IAP_ENVIRONMENT == 'Production':
expected_envs = {'Production', 'Sandbox'} # Sandbox for App Review
else:
expected_envs = {'Sandbox'}
if notif_env and notif_env not in expected_envs:
logger.warning(
'Apple webhook environment mismatch',
expected=settings.APPLE_IAP_ENVIRONMENT,
received=notif_env,
)
return web.Response(status=200) # ACK but ignore
logger.info(
'Apple notification received',
notification_type=notification_type,
subtype=subtype,
environment=notif_env,
)
# Handle notification types
if notification_type == 'TEST':
logger.info('Apple TEST notification received -- OK')
return web.Response(status=200)
if notification_type == 'REFUND':
await self._handle_apple_refund(notification, apple_service)
return web.Response(status=200)
if notification_type == 'REFUND_REVERSED':
await self._handle_apple_refund_reversed(notification)
return web.Response(status=200)
if notification_type == 'CONSUMPTION_REQUEST':
await self._handle_apple_consumption_request(notification, apple_service)
return web.Response(status=200)
if notification_type in ('ONE_TIME_CHARGE', 'REFUND_DECLINED'):
logger.info('Apple notification logged', notification_type=notification_type)
return web.Response(status=200)
logger.info('Unhandled Apple notification type', notification_type=notification_type)
return web.Response(status=200)
except Exception as e:
logger.error('Критическая ошибка обработки Apple IAP webhook', error=e, exc_info=True)
return web.Response(status=500)
async def _handle_apple_refund(self, notification: dict, apple_service) -> None:
"""Handle REFUND notification -- deduct credited balance."""
try:
data = notification.get('data', {})
signed_txn_info = data.get('signedTransactionInfo')
if not signed_txn_info:
logger.warning('No signedTransactionInfo in REFUND notification')
return
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
if not txn_info:
logger.warning('Failed to verify REFUND transaction info')
return
apple_txn_id = str(txn_info.get('transactionId') or '')
original_txn_id = str(txn_info.get('originalTransactionId') or '')
product_id = txn_info.get('productId', '')
from app.database.crud.apple_iap import (
mark_apple_transaction_refunded,
)
from app.database.crud.user import lock_user_for_pricing
from app.database.database import AsyncSessionLocal
from app.database.models import PaymentMethod, TransactionType
lookup_id = original_txn_id or apple_txn_id
async with AsyncSessionLocal() as db:
from app.database.crud.apple_iap import get_apple_transaction_by_transaction_id_for_update
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, lookup_id)
if not apple_txn:
# Try the other ID
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, apple_txn_id)
if not apple_txn:
logger.warning(
'Apple REFUND: transaction not found',
transaction_id=apple_txn_id,
original_transaction_id=original_txn_id,
)
return
if apple_txn.status == 'refunded':
logger.info('Apple REFUND: already refunded', transaction_id=lookup_id)
return
if apple_txn.environment == 'Sandbox' and settings.APPLE_IAP_ENVIRONMENT == 'Production':
logger.info(
'Apple REFUND: ignoring sandbox refund on production',
transaction_id=lookup_id,
user_id=apple_txn.user_id,
)
return
# FIX 6: Lock user row with FOR UPDATE before reading balance
# to prevent race condition in min() balance cap calculation
user = await lock_user_for_pricing(db, apple_txn.user_id)
if not user:
logger.error('Apple REFUND: user not found', user_id=apple_txn.user_id)
return
# Cap deduction to current balance to prevent negative balance
refund_amount = min(apple_txn.amount_kopeks, user.balance_kopeks)
if refund_amount < apple_txn.amount_kopeks:
logger.warning(
'Apple REFUND: partial balance deduction (user already spent funds)',
full_amount=apple_txn.amount_kopeks,
deducted=refund_amount,
user_balance=user.balance_kopeks,
user_id=user.id,
)
# Disable active subscriptions -- funds were spent and refunded
from app.database.crud.subscription import (
deactivate_subscription,
get_active_subscriptions_by_user_id,
)
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
for sub in active_subs:
await deactivate_subscription(db, sub, commit=False)
logger.warning(
'Apple REFUND: disabled subscription due to insufficient balance',
subscription_id=sub.id,
user_id=user.id,
)
if refund_amount > 0:
from app.database.crud.user import subtract_user_balance
await subtract_user_balance(
db=db,
user=user,
amount_kopeks=refund_amount,
description=f'Возврат Apple IAP: {product_id}',
create_transaction=True,
payment_method=PaymentMethod.APPLE_IAP,
transaction_type=TransactionType.REFUND,
commit=False,
)
await mark_apple_transaction_refunded(db, apple_txn.transaction_id)
await db.commit()
logger.info(
'Apple REFUND processed',
transaction_id=apple_txn.transaction_id,
amount_kopeks=apple_txn.amount_kopeks,
user_id=user.id,
)
except Exception as e:
logger.error('Error handling Apple REFUND', error=e, exc_info=True)
async def _handle_apple_refund_reversed(self, notification: dict) -> None:
"""Handle REFUND_REVERSED -- re-credit balance that was previously deducted."""
try:
data = notification.get('data', {})
signed_txn_info = data.get('signedTransactionInfo')
if not signed_txn_info:
logger.warning('No signedTransactionInfo in REFUND_REVERSED notification')
return
from app.external.apple_iap import AppleIAPService
apple_service = AppleIAPService()
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
if not txn_info:
logger.warning('Failed to verify REFUND_REVERSED transaction info')
return
apple_txn_id = str(txn_info.get('transactionId') or '')
original_txn_id = str(txn_info.get('originalTransactionId') or '')
product_id = txn_info.get('productId', '')
from app.database.crud.apple_iap import (
get_apple_transaction_by_transaction_id_for_update,
)
from app.database.crud.user import add_user_balance, get_user_by_id
from app.database.database import AsyncSessionLocal
from app.database.models import PaymentMethod
lookup_id = original_txn_id or apple_txn_id
async with AsyncSessionLocal() as db:
# FIX 7: Use FOR UPDATE lock on apple_transactions row
# before checking status to prevent idempotency race
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, lookup_id)
if not apple_txn:
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, apple_txn_id)
if not apple_txn:
logger.warning(
'Apple REFUND_REVERSED: transaction not found',
transaction_id=apple_txn_id,
)
return
if apple_txn.status != 'refunded':
logger.info(
'Apple REFUND_REVERSED: transaction not in refunded state',
transaction_id=lookup_id,
status=apple_txn.status,
)
return
if apple_txn.environment == 'Sandbox' and settings.APPLE_IAP_ENVIRONMENT == 'Production':
logger.info(
'Apple REFUND_REVERSED: ignoring sandbox on production',
transaction_id=lookup_id,
)
return
user = await get_user_by_id(db, apple_txn.user_id)
if not user:
logger.error('Apple REFUND_REVERSED: user not found', user_id=apple_txn.user_id)
return
# Re-credit the balance
await add_user_balance(
db=db,
user=user,
amount_kopeks=apple_txn.amount_kopeks,
description=f'Отмена возврата Apple IAP: {product_id}',
payment_method=PaymentMethod.APPLE_IAP,
commit=False,
)
apple_txn.status = 'verified'
apple_txn.refunded_at = None
await db.commit()
logger.info(
'Apple REFUND_REVERSED processed -- balance re-credited',
transaction_id=lookup_id,
amount_kopeks=apple_txn.amount_kopeks,
user_id=user.id,
)
except Exception as e:
logger.error('Error handling Apple REFUND_REVERSED', error=e, exc_info=True)
async def _handle_apple_consumption_request(self, notification: dict, apple_service) -> None:
"""Handle CONSUMPTION_REQUEST -- send consumption info to Apple."""
try:
data = notification.get('data', {})
signed_txn_info = data.get('signedTransactionInfo')
if not signed_txn_info:
logger.warning('No signedTransactionInfo in CONSUMPTION_REQUEST')
return
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
if not txn_info:
logger.warning('Failed to verify CONSUMPTION_REQUEST transaction info')
return
apple_txn_id = str(txn_info.get('transactionId') or '')
environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT)
from app.database.crud.apple_iap import get_apple_transaction_by_transaction_id
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
apple_txn = await get_apple_transaction_by_transaction_id(db, apple_txn_id)
# Determine if balance was consumed (spent on subscriptions)
# consumptionStatus: 0 = undeclared, 1 = not consumed, 2 = partially consumed, 3 = fully consumed
consumption_status = 0
if apple_txn and apple_txn.status == 'verified':
consumption_status = 3 # Balance was credited and likely spent
# customerConsented must be false -- we cannot prompt the user
# in a server-to-server webhook. Apple accepts the response
# regardless, but the consumption data weight may be lower.
await apple_service.send_consumption_info(
transaction_id=apple_txn_id,
customer_consented=False,
consumption_status=consumption_status,
delivery_status=0, # 0 = delivered
platform=1, # 1 = Apple
environment=environment,
)
logger.info('Apple CONSUMPTION_REQUEST handled', transaction_id=apple_txn_id)
except Exception as e:
logger.error('Error handling Apple CONSUMPTION_REQUEST', error=e, exc_info=True)
+5 -4
View File
@@ -313,7 +313,7 @@ async def restore_backup_start(callback: types.CallbackQuery, db_user: User, db:
else:
text = """📥 <b>Восстановление из бекапа</b>
📎 Отправьте файл бекапа (.json или .json.gz)
📎 Отправьте файл бекапа (.json, .json.gz или .tar.gz)
<b>ВАЖНО:</b>
Файл должен быть создан этой системой бекапов
@@ -383,7 +383,7 @@ async def restore_backup_execute(callback: types.CallbackQuery, db_user: User, d
async def handle_backup_file_upload(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
if not message.document:
await message.answer(
'❌ Пожалуйста, отправьте файл бекапа (.json или .json.gz)',
'❌ Пожалуйста, отправьте файл бекапа (.json, .json.gz или .tar.gz)',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text='◀️ Отмена', callback_data='backup_panel')]]
),
@@ -391,10 +391,11 @@ async def handle_backup_file_upload(message: types.Message, db_user: User, db: A
return
document = message.document
allowed_extensions = ('.json', '.json.gz', '.tar.gz', '.tar')
if not (document.file_name.endswith('.json') or document.file_name.endswith('.json.gz')):
if not document.file_name or not any(document.file_name.endswith(ext) for ext in allowed_extensions):
await message.answer(
'❌ Неподдерживаемый формат файла. Загрузите .json или .json.gz файл',
'❌ Неподдерживаемый формат файла. Загрузите .json, .json.gz или .tar.gz файл',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text='◀️ Отмена', callback_data='backup_panel')]]
),
+17 -2
View File
@@ -63,7 +63,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
},
'payments': {
'title': '💳 Платежные системы',
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay, SeverPay и Telegram Stars.',
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay, SeverPay, PayPear, RollyPay и Telegram Stars.',
'icon': '💳',
'categories': (
'PAYMENT',
@@ -76,6 +76,10 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
'KASSA_AI',
'RIOPAY',
'SEVERPAY',
'PAYPEAR',
'ROLLYPAY',
'OVERPAY',
'AURAPAY',
'MULENPAY',
'PAL24',
'WATA',
@@ -154,7 +158,6 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
'LOG',
'MODERATION',
'DEBUG',
'EXTERNAL_ADMIN',
),
},
}
@@ -1260,6 +1263,18 @@ def _build_settings_keyboard(
elif category_key == 'SEVERPAY':
label = texts.t('PAYMENT_SEVERPAY', f'💳 {settings.get_severpay_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'severpay')])
elif category_key == 'PAYPEAR':
label = texts.t('PAYMENT_PAYPEAR', f'💳 {settings.get_paypear_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'paypear')])
elif category_key == 'ROLLYPAY':
label = texts.t('PAYMENT_ROLLYPAY', f'💳 {settings.get_rollypay_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'rollypay')])
elif category_key == 'OVERPAY':
label = texts.t('PAYMENT_OVERPAY', f'💳 {settings.get_overpay_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'overpay')])
elif category_key == 'AURAPAY':
label = texts.t('PAYMENT_AURAPAY', f'💳 {settings.get_aurapay_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'aurapay')])
if test_payment_buttons:
rows.extend(test_payment_buttons)
+2
View File
@@ -1559,6 +1559,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
Subscription.user_id == User.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
.correlate(User)
.exists()
)
query = select(sql_func.count(User.id)).where(base_filter, ~subquery)
@@ -1613,6 +1614,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
Subscription.user_id == User.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
.correlate(User)
.exists()
)
query = (
+3
View File
@@ -175,6 +175,7 @@ async def _build_notification_preview_message(language: str, notification_type:
message = template.format(
end_date=(now - timedelta(days=1)).strftime('%d.%m.%Y %H:%M'),
price=price_30_days,
tariff_label='',
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
@@ -213,6 +214,7 @@ async def _build_notification_preview_message(language: str, notification_type:
percent=percent,
expires_at=(now + timedelta(hours=valid_hours)).strftime('%d.%m.%Y %H:%M'),
trigger_days=3,
tariff_label='',
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
@@ -258,6 +260,7 @@ async def _build_notification_preview_message(language: str, notification_type:
percent=percent,
trigger_days=trigger_days,
expires_at=(now + timedelta(hours=valid_hours)).strftime('%d.%m.%Y %H:%M'),
tariff_label='',
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
+2 -4
View File
@@ -4518,11 +4518,9 @@ async def _grant_paid_subscription(
trial_squads: list[str] = []
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
trial_uuid = await get_random_trial_squad_uuid(db)
if trial_uuid:
trial_squads = [trial_uuid]
trial_squads = await get_effective_tariff_squad_uuids(db, None)
except Exception as error:
logger.error('Не удалось подобрать сквад при выдаче подписки админом', admin_id=admin_id, error=error)
+315
View File
@@ -0,0 +1,315 @@
"""Handler for Antilopay balance top-up."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _create_antilopay_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
payment_method_type: str | None = None,
):
"""
Common logic for creating Antilopay payment and sending response.
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_antilopay_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
payment_method_type=payment_method_type,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(
error_text,
parse_mode='HTML',
)
return
payment_url = result.get('payment_url')
display_name = settings.get_antilopay_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'\U0001f4b3 Оплатить {amount}\u20bd',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'ANTILOPAY_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}\u20bd</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info('Antilopay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_antilopay_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
# Validate amount
min_amount = settings.ANTILOPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.ANTILOPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}\u20bd',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}\u20bd',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
data = await state.get_data()
payment_method = data.get('payment_method', 'antilopay')
# antilopay_sbp → 'sbp', antilopay_card → 'card', antilopay_sberpay → 'sberpay', antilopay → None
payment_method_type = _extract_service_type(payment_method)
await state.clear()
await _create_antilopay_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
payment_method_type=payment_method_type,
)
ANTILOPAY_PAYMENT_METHODS = {'antilopay', 'antilopay_sbp', 'antilopay_card', 'antilopay_sberpay'}
ANTILOPAY_SERVICE_MAP: dict[str, str | None] = {
'antilopay': None,
'antilopay_sbp': 'sbp',
'antilopay_card': 'card',
'antilopay_sberpay': 'sberpay',
}
def _extract_service_type(payment_method: str) -> str | None:
return ANTILOPAY_SERVICE_MAP.get(payment_method)
async def _start_antilopay_topup_impl(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
payment_method: str,
):
"""Common logic for starting Antilopay top-up (generic / SBP / card / SberPay)."""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method=payment_method)
min_amount = settings.ANTILOPAY_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.ANTILOPAY_MAX_AMOUNT_KOPEKS // 100
# Choose display name based on sub-method
if payment_method == 'antilopay_sbp':
display_name = settings.get_antilopay_sbp_display_name()
elif payment_method == 'antilopay_card':
display_name = settings.get_antilopay_card_display_name()
elif payment_method == 'antilopay_sberpay':
display_name = settings.get_antilopay_sberpay_display_name()
else:
display_name = settings.get_antilopay_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'ANTILOPAY_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\u20bd\n'
'Максимум: {max_amount}\u20bd',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
@error_handler
async def start_antilopay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay')
@error_handler
async def start_antilopay_sbp_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay_sbp')
@error_handler
async def start_antilopay_card_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay_card')
@error_handler
async def start_antilopay_sberpay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay_sberpay')
+302
View File
@@ -0,0 +1,302 @@
"""Handler for AuraPay balance top-up."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _create_aurapay_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
payment_method_type: str | None = None,
):
"""
Common logic for creating AuraPay payment and sending response.
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_aurapay_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
payment_method_type=payment_method_type,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(
error_text,
parse_mode='HTML',
)
return
payment_url = result.get('payment_url')
display_name = settings.get_aurapay_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'\U0001f4b3 Оплатить {amount}\u20bd',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'AURAPAY_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}\u20bd</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info('AuraPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_aurapay_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
# Validate amount
min_amount = settings.AURAPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.AURAPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}\u20bd',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}\u20bd',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
data = await state.get_data()
payment_method = data.get('payment_method', 'aurapay')
# aurapay_sbp → 'sbp', aurapay_card → 'card', aurapay → None
payment_method_type = _extract_service_type(payment_method)
await state.clear()
await _create_aurapay_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
payment_method_type=payment_method_type,
)
AURAPAY_PAYMENT_METHODS = {'aurapay', 'aurapay_sbp', 'aurapay_card'}
AURAPAY_SERVICE_MAP: dict[str, str | None] = {
'aurapay': None,
'aurapay_sbp': 'sbp',
'aurapay_card': 'card',
}
def _extract_service_type(payment_method: str) -> str | None:
return AURAPAY_SERVICE_MAP.get(payment_method)
async def _start_aurapay_topup_impl(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
payment_method: str,
):
"""Common logic for starting AuraPay top-up (generic / SBP / card)."""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method=payment_method)
min_amount = settings.AURAPAY_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.AURAPAY_MAX_AMOUNT_KOPEKS // 100
# Choose display name based on sub-method
if payment_method == 'aurapay_sbp':
display_name = settings.get_aurapay_sbp_display_name()
elif payment_method == 'aurapay_card':
display_name = settings.get_aurapay_card_display_name()
else:
display_name = settings.get_aurapay_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'AURAPAY_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\u20bd\n'
'Максимум: {max_amount}\u20bd',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
@error_handler
async def start_aurapay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_aurapay_topup_impl(callback, db_user, state, 'aurapay')
@error_handler
async def start_aurapay_sbp_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_aurapay_topup_impl(callback, db_user, state, 'aurapay_sbp')
@error_handler
async def start_aurapay_card_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_aurapay_topup_impl(callback, db_user, state, 'aurapay_card')
+305
View File
@@ -0,0 +1,305 @@
"""Handler for Donut balance top-up (Donut P2P)."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
DONUT_PAYMENT_METHODS = {'donut', 'donut_card', 'donut_sbp', 'donut_sbp_qr'}
DONUT_SERVICE_MAP: dict[str, str | None] = {
'donut': None,
'donut_card': 'card',
'donut_sbp': 'sbp',
'donut_sbp_qr': 'sbp_qr',
}
def _extract_service_type(payment_method: str) -> str | None:
return DONUT_SERVICE_MAP.get(payment_method)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def _get_display_name(payment_method: str) -> str:
if payment_method == 'donut_card':
return settings.get_donut_card_display_name()
if payment_method == 'donut_sbp':
return settings.get_donut_sbp_display_name()
if payment_method == 'donut_sbp_qr':
return settings.get_donut_sbp_qr_display_name()
return settings.get_donut_display_name()
async def _create_donut_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
payment_method_type: str | None = None,
display_name: str | None = None,
):
"""Создаёт платёж Donut и отправляет ссылку пользователю."""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_donut_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
payment_method_type=payment_method_type,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(error_text, parse_mode='HTML')
return
payment_url = result.get('payment_url')
name = display_name or settings.get_donut_display_name()
pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}').format(
amount=f'{amount_rub:.0f}',
)
keyboard_buttons: list[list[InlineKeyboardButton]] = []
if payment_url:
keyboard_buttons.append([InlineKeyboardButton(text=pay_button_text, url=payment_url)])
keyboard_buttons.append(
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
]
)
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
if payment_url:
response_text = texts.t(
'DONUT_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Нажмите кнопку ниже для перехода к оплате.\n'
'После подтверждения платежа баланс будет пополнен автоматически.',
).format(name=name, amount=f'{amount_rub:.2f}')
else:
response_text = texts.t(
'DONUT_PAYMENT_PROCESSING',
'\U0001f4b3 <b>Платёж создан через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Платёж в обработке. Реквизиты будут отправлены отдельным сообщением.',
).format(name=name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(response_text, reply_markup=keyboard, parse_mode='HTML')
else:
await message_or_callback.answer(response_text, reply_markup=keyboard, parse_mode='HTML')
logger.info('Donut payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_donut_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""Обрабатывает сумму, введённую пользователем для Donut."""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
min_amount = settings.DONUT_MIN_AMOUNT_KOPEKS
max_amount = settings.DONUT_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
data = await state.get_data()
payment_method = data.get('payment_method', 'donut')
payment_method_type = _extract_service_type(payment_method)
display_name = _get_display_name(payment_method)
await state.clear()
await _create_donut_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
payment_method_type=payment_method_type,
display_name=display_name,
)
async def _start_donut_topup_impl(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
payment_method: str,
):
"""Стартует FSM ввода суммы для Donut."""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method=payment_method)
min_amount = settings.DONUT_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.DONUT_MAX_AMOUNT_KOPEKS // 100
display_name = _get_display_name(payment_method)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'DONUT_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\n'
'Максимум: {max_amount}',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
@error_handler
async def start_donut_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_donut_topup_impl(callback, db_user, state, 'donut')
@error_handler
async def start_donut_card_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_donut_topup_impl(callback, db_user, state, 'donut_card')
@error_handler
async def start_donut_sbp_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_donut_topup_impl(callback, db_user, state, 'donut_sbp')
@error_handler
async def start_donut_sbp_qr_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_donut_topup_impl(callback, db_user, state, 'donut_sbp_qr')
+302
View File
@@ -0,0 +1,302 @@
"""Handler for Etoplatezhi balance top-up."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _create_etoplatezhi_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
payment_method_type: str | None = None,
):
"""
Common logic for creating Etoplatezhi payment and sending response.
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_etoplatezhi_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
payment_method_type=payment_method_type,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(
error_text,
parse_mode='HTML',
)
return
payment_url = result.get('payment_url')
display_name = settings.get_etoplatezhi_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'\U0001f4b3 Оплатить {amount}\u20bd',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'ETOPLATEZHI_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}\u20bd</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info('Etoplatezhi payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_etoplatezhi_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
# Validate amount
min_amount = settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS
max_amount = settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}\u20bd',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}\u20bd',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
data = await state.get_data()
payment_method = data.get('payment_method', 'etoplatezhi')
# etoplatezhi_sbp → 'sbp', etoplatezhi_card → 'card', etoplatezhi → None
payment_method_type = _extract_service_type(payment_method)
await state.clear()
await _create_etoplatezhi_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
payment_method_type=payment_method_type,
)
ETOPLATEZHI_PAYMENT_METHODS = {'etoplatezhi', 'etoplatezhi_sbp', 'etoplatezhi_card'}
ETOPLATEZHI_SERVICE_MAP: dict[str, str | None] = {
'etoplatezhi': None,
'etoplatezhi_sbp': 'sbp',
'etoplatezhi_card': 'card',
}
def _extract_service_type(payment_method: str) -> str | None:
return ETOPLATEZHI_SERVICE_MAP.get(payment_method)
async def _start_etoplatezhi_topup_impl(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
payment_method: str,
):
"""Common logic for starting Etoplatezhi top-up (generic / SBP / card)."""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method=payment_method)
min_amount = settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS // 100
# Choose display name based on sub-method
if payment_method == 'etoplatezhi_sbp':
display_name = settings.get_etoplatezhi_sbp_display_name()
elif payment_method == 'etoplatezhi_card':
display_name = settings.get_etoplatezhi_card_display_name()
else:
display_name = settings.get_etoplatezhi_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'ETOPLATEZHI_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\u20bd\n'
'Максимум: {max_amount}\u20bd',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
@error_handler
async def start_etoplatezhi_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi')
@error_handler
async def start_etoplatezhi_sbp_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi_sbp')
@error_handler
async def start_etoplatezhi_card_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi_card')
+273
View File
@@ -0,0 +1,273 @@
"""Handler for Jupiter balance top-up (FPGate P2P v2.1)."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
JUPITER_PAYMENT_METHODS = {'jupiter', 'jupiter_sbp'}
JUPITER_SERVICE_MAP: dict[str, str | None] = {
'jupiter': None,
'jupiter_sbp': 'sbp',
}
def _extract_service_type(payment_method: str) -> str | None:
return JUPITER_SERVICE_MAP.get(payment_method)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _create_jupiter_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
payment_method_type: str | None = None,
):
"""Создаёт платёж Jupiter и отправляет ссылку/QR пользователю."""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_jupiter_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
payment_method_type=payment_method_type,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(error_text, parse_mode='HTML')
return
payment_url = result.get('payment_url')
display_name = settings.get_jupiter_display_name()
pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}').format(
amount=f'{amount_rub:.0f}',
)
keyboard_buttons: list[list[InlineKeyboardButton]] = []
if payment_url:
keyboard_buttons.append([InlineKeyboardButton(text=pay_button_text, url=payment_url)])
keyboard_buttons.append(
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
]
)
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
if payment_url:
response_text = texts.t(
'JUPITER_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Нажмите кнопку ниже, чтобы открыть QR-код СБП и оплатить.\n'
'Баланс будет пополнен автоматически после подтверждения платежа.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
else:
response_text = texts.t(
'JUPITER_PAYMENT_PROCESSING',
'\U0001f4b3 <b>Платёж создан через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Платёж в обработке. Реквизиты будут отправлены отдельным сообщением.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(response_text, reply_markup=keyboard, parse_mode='HTML')
else:
await message_or_callback.answer(response_text, reply_markup=keyboard, parse_mode='HTML')
logger.info('Jupiter payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_jupiter_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""Обрабатывает сумму, введённую пользователем для Jupiter."""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
min_amount = settings.JUPITER_MIN_AMOUNT_KOPEKS
max_amount = settings.JUPITER_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
data = await state.get_data()
payment_method = data.get('payment_method', 'jupiter')
payment_method_type = _extract_service_type(payment_method)
await state.clear()
await _create_jupiter_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
payment_method_type=payment_method_type,
)
async def _start_jupiter_topup_impl(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
payment_method: str,
):
"""Стартует FSM ввода суммы для Jupiter."""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method=payment_method)
min_amount = settings.JUPITER_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.JUPITER_MAX_AMOUNT_KOPEKS // 100
if payment_method == 'jupiter_sbp':
display_name = settings.get_jupiter_sbp_display_name()
else:
display_name = settings.get_jupiter_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'JUPITER_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\n'
'Максимум: {max_amount}',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
@error_handler
async def start_jupiter_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_jupiter_topup_impl(callback, db_user, state, 'jupiter')
@error_handler
async def start_jupiter_sbp_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_jupiter_topup_impl(callback, db_user, state, 'jupiter_sbp')
+284
View File
@@ -0,0 +1,284 @@
"""Handler for Lava balance top-up (Lava Business, gate.lava.ru)."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
LAVA_PAYMENT_METHODS = {'lava', 'lava_card', 'lava_sbp'}
LAVA_SERVICE_MAP: dict[str, str | None] = {
'lava': None,
'lava_card': 'card',
'lava_sbp': 'sbp',
}
def _extract_service_type(payment_method: str) -> str | None:
return LAVA_SERVICE_MAP.get(payment_method)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def _get_display_name(payment_method: str) -> str:
if payment_method == 'lava_card':
return settings.get_lava_card_display_name()
if payment_method == 'lava_sbp':
return settings.get_lava_sbp_display_name()
return settings.get_lava_display_name()
async def _create_lava_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
payment_method_type: str | None = None,
display_name: str | None = None,
):
"""Создаёт инвойс Lava и отправляет ссылку пользователю."""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_lava_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
payment_method_type=payment_method_type,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(error_text, parse_mode='HTML')
return
payment_url = result.get('payment_url')
name = display_name or settings.get_lava_display_name()
# Без URL mixin вернул бы None ещё до этого блока; здесь URL гарантирован.
pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}').format(
amount=f'{amount_rub:.0f}',
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=pay_button_text, url=payment_url)],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'LAVA_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Нажмите кнопку ниже для перехода к оплате.\n'
'После подтверждения платежа баланс будет пополнен автоматически.',
).format(name=name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(response_text, reply_markup=keyboard, parse_mode='HTML')
else:
await message_or_callback.answer(response_text, reply_markup=keyboard, parse_mode='HTML')
logger.info('Lava payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_lava_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""Обрабатывает сумму для Lava."""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
min_amount = settings.LAVA_MIN_AMOUNT_KOPEKS
max_amount = settings.LAVA_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
data = await state.get_data()
payment_method = data.get('payment_method', 'lava')
payment_method_type = _extract_service_type(payment_method)
display_name = _get_display_name(payment_method)
await state.clear()
await _create_lava_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
payment_method_type=payment_method_type,
display_name=display_name,
)
async def _start_lava_topup_impl(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
payment_method: str,
):
"""Стартует FSM ввода суммы для Lava."""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method=payment_method)
min_amount = settings.LAVA_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.LAVA_MAX_AMOUNT_KOPEKS // 100
display_name = _get_display_name(payment_method)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'LAVA_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\n'
'Максимум: {max_amount}',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
@error_handler
async def start_lava_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_lava_topup_impl(callback, db_user, state, 'lava')
@error_handler
async def start_lava_card_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_lava_topup_impl(callback, db_user, state, 'lava_card')
@error_handler
async def start_lava_sbp_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_lava_topup_impl(callback, db_user, state, 'lava_sbp')
+116 -7
View File
@@ -163,6 +163,55 @@ async def route_payment_by_method(
await process_rollypay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'overpay':
from .overpay import process_overpay_payment_amount
async with AsyncSessionLocal() as db:
await process_overpay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method in ('aurapay', 'aurapay_sbp', 'aurapay_card'):
from .aurapay import process_aurapay_payment_amount
async with AsyncSessionLocal() as db:
await process_aurapay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method in ('etoplatezhi', 'etoplatezhi_sbp', 'etoplatezhi_card'):
from .etoplatezhi import process_etoplatezhi_payment_amount
async with AsyncSessionLocal() as db:
await process_etoplatezhi_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method in ('antilopay', 'antilopay_sbp', 'antilopay_card', 'antilopay_sberpay'):
from .antilopay import process_antilopay_payment_amount
async with AsyncSessionLocal() as db:
await process_antilopay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method in ('jupiter', 'jupiter_sbp'):
from .jupiter import process_jupiter_payment_amount
async with AsyncSessionLocal() as db:
await process_jupiter_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method in ('donut', 'donut_card', 'donut_sbp', 'donut_sbp_qr'):
from .donut import process_donut_payment_amount
async with AsyncSessionLocal() as db:
await process_donut_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method in ('lava', 'lava_card', 'lava_sbp'):
from .lava import process_lava_payment_amount
async with AsyncSessionLocal() as db:
await process_lava_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'riopay':
from .riopay import process_riopay_payment_amount
@@ -484,12 +533,16 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
amount_rubles = float(amount_text.replace(',', '.'))
if amount_rubles < 1:
await message.answer('Минимальная сумма пополнения: 1 ₽', reply_markup=get_back_keyboard(db_user.language))
await message.answer(
'Минимальная сумма пополнения: 1 ₽',
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
)
return
if amount_rubles > 50000:
await message.answer(
'Максимальная сумма пополнения: 50,000 ₽', reply_markup=get_back_keyboard(db_user.language)
'Максимальная сумма пополнения: 50,000 ₽',
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
)
return
@@ -502,7 +555,7 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(
f'❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f}',
reply_markup=get_back_keyboard(db_user.language),
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
)
return
@@ -510,7 +563,7 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(
f'❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f}'.replace(',', ' '),
reply_markup=get_back_keyboard(db_user.language),
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
)
return
@@ -586,6 +639,7 @@ async def handle_topup_amount_callback(
platega_method_code = int(method[len('platega_m') :])
await state.update_data(payment_method='platega', platega_method=platega_method_code)
await state.set_state(BalanceStates.waiting_for_amount)
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(callback.message, db_user, db, amount_kopeks, state)
elif method == 'platega':
@@ -597,6 +651,7 @@ async def handle_topup_amount_callback(
method_code = int(data.get('platega_method', 0)) if data else 0
if method_code > 0:
await state.set_state(BalanceStates.waiting_for_amount)
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(callback.message, db_user, db, amount_kopeks, state)
else:
@@ -608,9 +663,12 @@ async def handle_topup_amount_callback(
await start_tribute_payment(callback, db_user)
return
# Стандартные методы через роутер
elif not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, method):
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
return
else:
await state.update_data(payment_method=method)
await state.set_state(BalanceStates.waiting_for_amount)
if not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, method):
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
return
await callback.answer()
@@ -741,6 +799,57 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_rollypay_topup, F.data == 'topup_rollypay')
from .overpay import start_overpay_topup
dp.callback_query.register(start_overpay_topup, F.data == 'topup_overpay')
from .aurapay import start_aurapay_card_topup, start_aurapay_sbp_topup, start_aurapay_topup
dp.callback_query.register(start_aurapay_topup, F.data == 'topup_aurapay')
dp.callback_query.register(start_aurapay_sbp_topup, F.data == 'topup_aurapay_sbp')
dp.callback_query.register(start_aurapay_card_topup, F.data == 'topup_aurapay_card')
from .etoplatezhi import start_etoplatezhi_card_topup, start_etoplatezhi_sbp_topup, start_etoplatezhi_topup
dp.callback_query.register(start_etoplatezhi_topup, F.data == 'topup_etoplatezhi')
dp.callback_query.register(start_etoplatezhi_sbp_topup, F.data == 'topup_etoplatezhi_sbp')
dp.callback_query.register(start_etoplatezhi_card_topup, F.data == 'topup_etoplatezhi_card')
from .antilopay import (
start_antilopay_card_topup,
start_antilopay_sberpay_topup,
start_antilopay_sbp_topup,
start_antilopay_topup,
)
dp.callback_query.register(start_antilopay_topup, F.data == 'topup_antilopay')
dp.callback_query.register(start_antilopay_sbp_topup, F.data == 'topup_antilopay_sbp')
dp.callback_query.register(start_antilopay_card_topup, F.data == 'topup_antilopay_card')
dp.callback_query.register(start_antilopay_sberpay_topup, F.data == 'topup_antilopay_sberpay')
from .jupiter import start_jupiter_sbp_topup, start_jupiter_topup
dp.callback_query.register(start_jupiter_topup, F.data == 'topup_jupiter')
dp.callback_query.register(start_jupiter_sbp_topup, F.data == 'topup_jupiter_sbp')
from .donut import (
start_donut_card_topup,
start_donut_sbp_qr_topup,
start_donut_sbp_topup,
start_donut_topup,
)
dp.callback_query.register(start_donut_topup, F.data == 'topup_donut')
dp.callback_query.register(start_donut_card_topup, F.data == 'topup_donut_card')
dp.callback_query.register(start_donut_sbp_topup, F.data == 'topup_donut_sbp')
dp.callback_query.register(start_donut_sbp_qr_topup, F.data == 'topup_donut_sbp_qr')
from .lava import start_lava_card_topup, start_lava_sbp_topup, start_lava_topup
dp.callback_query.register(start_lava_topup, F.data == 'topup_lava')
dp.callback_query.register(start_lava_card_topup, F.data == 'topup_lava_card')
dp.callback_query.register(start_lava_sbp_topup, F.data == 'topup_lava_sbp')
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
+247
View File
@@ -0,0 +1,247 @@
"""Handler for Overpay balance top-up."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _create_overpay_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
):
"""
Common logic for creating Overpay payment and sending response.
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_overpay_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(
error_text,
parse_mode='HTML',
)
return
payment_url = result.get('payment_url')
display_name = settings.get_overpay_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'\U0001f4b3 Оплатить {amount}\u20bd',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'OVERPAY_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}\u20bd</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info('Overpay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_overpay_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
# Validate amount
min_amount = settings.OVERPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.OVERPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}\u20bd',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}\u20bd',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
await state.clear()
await _create_overpay_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
)
@error_handler
async def start_overpay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Start Overpay top-up process - ask for amount.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method='overpay')
min_amount = settings.OVERPAY_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.OVERPAY_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_overpay_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'OVERPAY_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\u20bd\n'
'Максимум: {max_amount}\u20bd',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
+5 -2
View File
@@ -42,6 +42,7 @@ async def _prompt_amount(
# Если сумма уже известна (например, после быстрого выбора),
# сразу создаём платеж и сбрасываем временное значение.
await state.update_data(platega_pending_amount=None)
await state.set_state(BalanceStates.waiting_for_amount)
from app.database.database import AsyncSessionLocal
@@ -294,8 +295,9 @@ async def process_platega_payment_amount(
'PLATEGA_AMOUNT_TOO_LOW',
'Минимальная сумма для оплаты через Platega: {amount}',
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)),
reply_markup=get_back_keyboard(db_user.language),
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
)
await state.set_state(BalanceStates.waiting_for_amount)
return
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
@@ -304,8 +306,9 @@ async def process_platega_payment_amount(
'PLATEGA_AMOUNT_TOO_HIGH',
'Максимальная сумма для оплаты через Platega: {amount}',
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS)),
reply_markup=get_back_keyboard(db_user.language),
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
)
await state.set_state(BalanceStates.waiting_for_amount)
return
try:
+13 -2
View File
@@ -118,12 +118,23 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(handle_cancel, F.data.in_(['cancel', 'subscription_cancel']))
# Самый последний: ловим любые неизвестные текстовые сообщения
# Исключаем специальные сервисные события (например, успешные платежи),
# чтобы их обработка не прерывалась общим хендлером неизвестных сообщений
# Исключаем специальные сервисные события (например, успешные платежи)
# и сообщения от самого бота (например, фото главного меню)
dp.message.register(
handle_unknown_message,
StateFilter(None),
F.from_user.is_bot.is_(False),
F.successful_payment.is_(None),
F.text.is_not(None),
~F.text.startswith('/'),
)
# Ловим медиа-сообщения (фото, видео, документы, стикеры и т.д.)
# без активного состояния — чтобы пользователь знал, что бот не принял медиа
dp.message.register(
handle_unknown_message,
StateFilter(None),
F.from_user.is_bot.is_(False),
F.successful_payment.is_(None),
F.text.is_(None),
)
+3 -4
View File
@@ -120,8 +120,7 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
# Show bot link
referral_text += (
texts.t('REFERRAL_BOT_LINK_TITLE', '🤖 <b>Ссылка на бота:</b>')
+ f'\n<code>{html_escape(bot_referral_link)}</code>\n'
texts.t('REFERRAL_BOT_LINK_TITLE', '🤖 <b>Ссылка на бота:</b>') + f'\n{html_escape(bot_referral_link)}\n'
)
# Show cabinet link if configured
@@ -129,7 +128,7 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
referral_text += (
'\n'
+ texts.t('REFERRAL_CABINET_LINK_TITLE', '🌐 <b>Ссылка на кабинет:</b>')
+ f'\n<code>{html_escape(cabinet_referral_link)}</code>\n'
+ f'\n{html_escape(cabinet_referral_link)}\n'
)
referral_text += (
@@ -551,7 +550,7 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
'Нажмите на текст ниже, чтобы скопировать:',
)
+ '\n\n'
f'<blockquote><code>{html_escape(invite_text)}</code></blockquote>'
f'<blockquote>{html_escape(invite_text)}</blockquote>'
),
keyboard,
)
+55 -3
View File
@@ -49,7 +49,11 @@ from app.services.pinned_message_service import (
get_active_pinned_message,
)
from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.referral_service import process_referral_registration, save_pending_referral
from app.services.referral_service import (
process_referral_registration,
save_pending_campaign,
save_pending_referral,
)
from app.services.subscription_service import SubscriptionService
from app.services.support_settings_service import SupportSettingsService
from app.services.web_auth_service import WEB_AUTH_TOKEN_MIN_LENGTH, link_web_auth_token
@@ -386,6 +390,17 @@ async def _apply_campaign_bonus_if_needed(
if not result.success:
return None
# Bot-flow successfully applied the campaign — clear the Redis pending entry
# (set in cmd_start as a fallback for the cabinet WebApp path) so it isn't
# re-evaluated on a subsequent cabinet login.
try:
from app.services.referral_service import clear_pending_campaign
if getattr(user, 'telegram_id', None):
await clear_pending_campaign(user.telegram_id)
except Exception:
pass
if result.bonus_type == 'balance':
amount_text = texts.format_price(result.balance_kopeks)
return texts.CAMPAIGN_BONUS_BALANCE.format(
@@ -724,6 +739,23 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
start_parameter=campaign.start_parameter,
)
await state.update_data(campaign_id=campaign.id)
# Persist campaign to Redis immediately so it survives if user opens
# miniapp/cabinet (via Telegram menu button) before completing the
# bot registration flow. Mirrors the pending_referral mechanism.
# Only for new users — existing users already had attribution applied.
if not db_user:
try:
await save_pending_campaign(
message.from_user.id,
campaign.start_parameter,
campaign.id,
)
except Exception as exc:
logger.warning(
'Failed to persist pending campaign',
campaign_id=campaign.id,
error=exc,
)
if campaign.partner_user_id:
await state.update_data(referrer_id=campaign.partner_user_id)
logger.info(
@@ -1148,14 +1180,18 @@ async def _show_privacy_policy_after_rules(
logger.info('🔒 Используется политика конфиденциальности из БД для языка', language=language)
try:
await callback.message.edit_text(privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language))
await callback.message.edit_text(
privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language), parse_mode='HTML'
)
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
logger.info('🔒 Политика конфиденциальности отправлена пользователю', from_user_id=callback.from_user.id)
return True
except Exception as e:
logger.error('Ошибка при показе политики конфиденциальности', error=e, exc_info=True)
try:
await callback.message.answer(privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language))
await callback.message.answer(
privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language), parse_mode='HTML'
)
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
logger.info(
'🔒 Политика конфиденциальности отправлена новым сообщением пользователю',
@@ -1725,6 +1761,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
await callback.message.answer(
offer_text,
reply_markup=get_post_registration_keyboard(user.language),
parse_mode='HTML',
)
logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id)
if pinned_message and not pinned_message.send_before_menu:
@@ -2079,6 +2116,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
await message.answer(
offer_text,
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id)
if pinned_message and not pinned_message.send_before_menu:
@@ -2410,6 +2448,20 @@ async def required_sub_channel_check(
campaign_id=campaign.id,
partner_user_id=campaign.partner_user_id,
)
# Mirror save in Redis so cabinet WebApp auth can pick it up
# if user opens miniapp before completing registration.
try:
await save_pending_campaign(
query.from_user.id,
campaign.start_parameter,
campaign.id,
)
except Exception as exc:
logger.warning(
'Failed to persist pending campaign after channel check',
campaign_id=campaign.id,
error=exc,
)
else:
state_data['referral_code'] = pending_start_payload
logger.info(
+12
View File
@@ -101,6 +101,18 @@ async def toggle_autopay(callback: types.CallbackQuery, db_user: User, db: Async
enable = callback.data.startswith('autopay_enable')
if enable:
# Trial subscriptions cannot use autopay
if subscription.is_trial or subscription.is_trial is None:
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
'AUTOPAY_NOT_AVAILABLE_TRIAL',
'Автоплатеж недоступен для пробных подписок.',
),
show_alert=True,
)
return
# Classic subscriptions cannot use autopay when tariff mode is enabled
if settings.is_tariffs_mode() and not subscription.tariff_id:
texts = get_texts(db_user.language)
+29 -2
View File
@@ -1,6 +1,7 @@
import asyncio
import base64
import html as html_mod
import math
import re
import time
from datetime import UTC, datetime
@@ -405,11 +406,37 @@ async def get_apps_for_platform_async(device_type: str, language: str = 'ru') ->
def normalize_app(app: dict[str, Any]) -> dict[str, Any]:
"""Normalize Remnawave app dict to a unified format with blocks."""
# Extract urlScheme from blocks if not present at root level
url_scheme = app.get('urlScheme', '')
if not url_scheme:
# Try to extract from subscriptionLink button in blocks
blocks = app.get('blocks', [])
for block in blocks:
if not isinstance(block, dict):
continue
buttons = block.get('buttons', [])
for btn in buttons:
if not isinstance(btn, dict):
continue
if btn.get('type') == 'subscriptionLink':
link = btn.get('link', '') or btn.get('url', '')
if '{{SUBSCRIPTION_LINK}}' in link:
url_scheme = link.split('{{SUBSCRIPTION_LINK}}')[0]
break
if url_scheme:
break
# Validate extracted scheme contains ://
if url_scheme and '://' not in url_scheme:
url_scheme = ''
return {
'id': app.get('id', app.get('name', 'unknown')),
'name': app.get('name', ''),
'isFeatured': app.get('featured', app.get('isFeatured', False)),
'urlScheme': app.get('urlScheme', ''),
'urlScheme': url_scheme,
'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False),
'blocks': app.get('blocks', []),
'_raw': app,
@@ -545,7 +572,7 @@ def get_traffic_switch_keyboard(
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
price_multiplier = days_left / 30
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
else:
+2 -1
View File
@@ -1,4 +1,5 @@
import html
import math
from datetime import UTC, datetime
from aiogram import types
@@ -266,7 +267,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
logger.info('🔧 Добавлено: Удалено', added=added, removed=removed)
now = datetime.now(UTC)
days_to_pay = max(1, (subscription.end_date - now).days)
days_to_pay = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_to_pay if days_to_pay > 0 else None
+43 -13
View File
@@ -1,4 +1,5 @@
import html as html_mod
import math
from datetime import UTC, datetime
from aiogram import types
@@ -325,9 +326,14 @@ async def confirm_change_devices(
if devices_difference > 0:
additional_devices = devices_difference
# Для тарифов - все устройства платные (нет бесплатного лимита)
# Устройства в пределах тарифного лимита — бесплатные
if tariff:
chargeable_devices = additional_devices
tariff_included = tariff.device_limit or 0
if current_devices < tariff_included:
free_devices = tariff_included - current_devices
chargeable_devices = max(0, additional_devices - free_devices)
else:
chargeable_devices = additional_devices
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
chargeable_devices = max(0, additional_devices - free_devices)
@@ -338,7 +344,7 @@ async def confirm_change_devices(
# Считаем стоимость по оставшимся дням подписки
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_left
devices_discount_percent = PricingEngine.get_addon_discount_percent(
@@ -352,7 +358,8 @@ async def confirm_change_devices(
)
# Цена = месячная_цена * days_left / 30
price = int(discounted_per_month * days_left / 30)
price = max(100, price) # Минимум 1 рубль
if chargeable_devices > 0:
price = max(100, price) # Минимум 1 рубль (только для платных устройств)
total_discount = int(discount_per_month * days_left / 30)
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
@@ -553,7 +560,12 @@ async def execute_change_devices(
devices_difference = new_devices_count - current_devices
if devices_difference > 0:
if tariff:
chargeable_devices = devices_difference
tariff_included = tariff.device_limit or 0
if current_devices < tariff_included:
free_devices = tariff_included - current_devices
chargeable_devices = max(0, devices_difference - free_devices)
else:
chargeable_devices = devices_difference
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
chargeable_devices = max(0, devices_difference - free_devices)
@@ -561,7 +573,7 @@ async def execute_change_devices(
chargeable_devices = devices_difference
devices_price_per_month = chargeable_devices * price_per_device
days_left = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_left = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
devices_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'devices',
@@ -572,7 +584,8 @@ async def execute_change_devices(
devices_discount_percent,
)
price = int(discounted_per_month * days_left / 30)
price = max(100, price)
if chargeable_devices > 0:
price = max(100, price)
else:
price = 0
@@ -589,7 +602,7 @@ async def execute_change_devices(
)
return
charged_days = max(1, (subscription.end_date - datetime.now(UTC)).days)
charged_days = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
await create_transaction(
db=db,
user_id=db_user.id,
@@ -1215,7 +1228,22 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
)
return
devices_price_per_month = devices_count * price_per_device
# Устройства в пределах тарифного лимита — бесплатные
current_devices = subscription.device_limit or 1
if tariff:
tariff_included = tariff.device_limit or 0
if current_devices < tariff_included:
free_devices = tariff_included - current_devices
chargeable_devices = max(0, devices_count - free_devices)
else:
chargeable_devices = devices_count
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
chargeable_devices = max(0, devices_count - free_devices)
else:
chargeable_devices = devices_count
devices_price_per_month = chargeable_devices * price_per_device
# TOCTOU: lock user row before reading promo/discount state
db_user = await lock_user_for_pricing(db, db_user.id)
@@ -1226,7 +1254,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
if is_daily_tariff:
# Для суточных тарифов считаем по дням (как в кабинете)
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_left
devices_discount_percent = PricingEngine.get_addon_discount_percent(
@@ -1240,13 +1268,14 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
)
# Цена = месячная_цена * days_left / 30
price = int(discounted_per_month * days_left / 30)
price = max(100, price) # Минимум 1 рубль
if chargeable_devices > 0:
price = max(100, price) # Минимум 1 рубль (только для платных устройств)
total_discount = int(discount_per_month * days_left / 30)
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
else:
# Для обычных тарифов - по дням (как в кабинете)
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_left
devices_discount_percent = PricingEngine.get_addon_discount_percent(
@@ -1260,7 +1289,8 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
)
# Цена = месячная_цена * days_left / 30
price = int(discounted_per_month * days_left / 30)
price = max(100, price) # Минимум 1 рубль
if chargeable_devices > 0:
price = max(100, price) # Минимум 1 рубль (только для платных устройств)
total_discount = int(discount_per_month * days_left / 30)
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
@@ -132,6 +132,16 @@ def _build_subscription_detail_keyboard(sub_id: int, sub=None) -> types.InlineKe
if is_inactive:
buttons.append([types.InlineKeyboardButton(text='🗑 Удалить подписку', callback_data=f'sub_del:{sub_id}')])
if not is_inactive and settings.is_subscription_revoke_enabled():
buttons.append(
[
types.InlineKeyboardButton(
text='🔄 Перевыпустить',
callback_data=f'sr:{sub_id}',
)
]
)
buttons.append([types.InlineKeyboardButton(text='◀️ К списку подписок', callback_data='my_subscriptions')])
return types.InlineKeyboardMarkup(inline_keyboard=buttons)
+42 -8
View File
@@ -922,9 +922,11 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
if trial_tariff:
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
trial_traffic_limit = trial_tariff.traffic_limit_gb
trial_device_limit = trial_tariff.device_limit
trial_squads = trial_tariff.allowed_squads or []
trial_squads = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
tariff_id_for_trial = trial_tariff.id
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
@@ -937,7 +939,7 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
except Exception as e:
logger.error('Ошибка получения триального тарифа', error=e)
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
# No trial tariff configured, use the legacy random trial squad fallback.
if not trial_squads:
from app.database.crud.server_squad import get_random_trial_squad_uuid
@@ -1579,11 +1581,15 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext,
if settings.is_traffic_fixed():
traffic_value = prepared_cart_data.get('traffic_gb')
if traffic_value is None:
traffic_value = prepared_cart_data.get('traffic_limit_gb')
if traffic_value is None:
traffic_value = settings.get_fixed_traffic_limit()
traffic_display = 'Безлимитный' if traffic_value == 0 else f'{traffic_value} ГБ'
else:
traffic_value = prepared_cart_data.get('traffic_gb', 0) or 0
traffic_value = prepared_cart_data.get('traffic_gb')
if traffic_value is None:
traffic_value = prepared_cart_data.get('traffic_limit_gb', 0)
traffic_display = 'Безлимитный' if traffic_value == 0 else f'{traffic_value} ГБ'
summary_lines = [
@@ -1596,6 +1602,8 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext,
if settings.is_devices_selection_enabled():
devices_value = prepared_cart_data.get('devices')
if devices_value is None:
devices_value = prepared_cart_data.get('device_limit')
if devices_value is not None:
summary_lines.append(f'📱 Устройства: {devices_value}')
@@ -1970,7 +1978,10 @@ async def confirm_extend_subscription(
'description': f'Продление подписки на {days} дней',
'consume_promo_offer': bool(promo_offer_discount > 0),
'device_limit': device_limit,
'devices': device_limit,
'traffic_limit_gb': renewal_traffic_gb,
'traffic_gb': renewal_traffic_gb,
'countries': list(subscription.connected_squads or []),
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
@@ -2225,7 +2236,12 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
devices_selection_enabled = settings.is_devices_selection_enabled()
forced_disabled_limit: int | None = None
if devices_selection_enabled:
devices_selected = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
# Для extend-корзины ключ может быть 'device_limit' вместо 'devices'
devices_selected = data.get('devices')
if devices_selected is None:
devices_selected = data.get('device_limit')
if devices_selected is None:
devices_selected = settings.DEFAULT_DEVICE_LIMIT
else:
forced_disabled_limit = settings.get_disabled_mode_device_limit()
if forced_disabled_limit is None:
@@ -3307,9 +3323,11 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
if trial_tariff_id > 0:
trial_tariff = await _get_tariff(db, trial_tariff_id)
if trial_tariff:
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
trial_traffic_limit = trial_tariff.traffic_limit_gb
trial_device_limit = trial_tariff.device_limit
trial_squads = trial_tariff.allowed_squads or []
trial_squads = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
tariff_id_for_trial = trial_tariff.id
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
@@ -3322,7 +3340,7 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
except Exception as e:
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
# No trial tariff configured, use the legacy random trial squad fallback.
if not trial_squads:
from app.database.crud.server_squad import get_random_trial_squad_uuid
@@ -3673,9 +3691,11 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
if trial_tariff_id > 0:
trial_tariff = await _get_tariff(db, trial_tariff_id)
if trial_tariff:
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
trial_traffic = trial_tariff.traffic_limit_gb
trial_devices = trial_tariff.device_limit
trial_squads_list = trial_tariff.allowed_squads or []
trial_squads_list = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
tariff_id_for_trial = trial_tariff.id
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
@@ -3688,7 +3708,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
except Exception as e:
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
# Если тариф не задал серверы, получаем случайный сквад
# Если триальный тариф не найден, используем legacy fallback со случайным сквадом.
if not trial_squads_list:
from app.database.crud.server_squad import get_random_trial_squad_uuid
@@ -4121,6 +4141,17 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(handle_change_devices_menu, F.data.startswith('change_devices_menu:'))
dp.callback_query.register(handle_device_management_menu, F.data.startswith('device_management:'))
# Subscription revoke (reissue)
from app.handlers.subscription.revoke import (
confirm_subscription_revoke,
start_multi_revoke,
start_subscription_revoke,
)
dp.callback_query.register(start_subscription_revoke, F.data == 'subscription_revoke')
dp.callback_query.register(confirm_subscription_revoke, F.data == 'subscription_revoke_confirm')
dp.callback_query.register(start_multi_revoke, F.data.startswith('sr:'))
dp.callback_query.register(show_trial_offer, F.data == 'menu_trial')
dp.callback_query.register(activate_trial, F.data == 'trial_activate')
@@ -4506,8 +4537,11 @@ async def _extend_existing_subscription(
'return_to_cart': True,
'description': f'Продление подписки на {period_days} дней',
'device_limit': device_limit,
'devices': device_limit,
'traffic_limit_gb': traffic_limit_gb,
'traffic_gb': traffic_limit_gb,
'squad_uuid': squad_uuid,
'countries': [squad_uuid] if squad_uuid else [],
'consume_promo_offer': consume_promo,
}
+350
View File
@@ -0,0 +1,350 @@
"""Handler for subscription reissue (revoke + regenerate link)."""
from __future__ import annotations
from datetime import UTC, datetime
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InaccessibleMessage, InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.subscription import get_subscription_by_id_for_user
from app.database.models import Subscription, User
from app.localization.texts import get_texts
from app.services.subscription_service import SubscriptionService
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
def _check_revoke_cooldown(subscription: Subscription) -> int | None:
"""Returns remaining seconds if on cooldown, None if ready."""
if not subscription.last_revoke_at:
return None
elapsed = (datetime.now(UTC) - subscription.last_revoke_at).total_seconds()
cooldown = settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS
if elapsed < cooldown:
return int(cooldown - elapsed)
return None
def _build_revoke_confirm_keyboard(
language: str,
multi_tariff: bool = False,
) -> InlineKeyboardMarkup:
"""Build confirmation keyboard for revoke action."""
texts = get_texts(language)
back_callback = 'my_subscriptions' if multi_tariff else 'subscription_settings'
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('SUBSCRIPTION_REVOKE_CONFIRM_BTN', '✅ Подтвердить'),
callback_data='subscription_revoke_confirm',
),
],
[
InlineKeyboardButton(
text=texts.BACK,
callback_data=back_callback,
),
],
]
)
def _build_revoke_success_keyboard(
language: str,
multi_tariff: bool = False,
) -> InlineKeyboardMarkup:
"""Build success keyboard with connect and back buttons."""
texts = get_texts(language)
back_callback = 'my_subscriptions' if multi_tariff else 'menu_subscription'
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('SUBSCRIPTION_REVOKE_CONNECT_BTN', '🔗 Подключиться'),
callback_data='subscription_connect',
),
],
[
InlineKeyboardButton(
text=texts.BACK,
callback_data=back_callback,
),
],
]
)
# ---------------------------------------------------------------------------
# Classic mode (single subscription)
# ---------------------------------------------------------------------------
@error_handler
async def start_subscription_revoke(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext | None = None,
) -> None:
"""Show revoke confirmation for classic single-subscription mode."""
if isinstance(callback.message, InaccessibleMessage):
await callback.answer()
return
texts = get_texts(db_user.language)
if not settings.is_subscription_revoke_enabled():
await callback.answer(
texts.t('SUBSCRIPTION_REVOKE_DISABLED', 'Перевыпуск подписки недоступен'),
show_alert=True,
)
return
subscription = db_user.subscription
if not subscription or not subscription.is_active:
await callback.answer(
texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
show_alert=True,
)
return
# Check cooldown
remaining = _check_revoke_cooldown(subscription)
if remaining is not None:
minutes = remaining // 60
seconds = remaining % 60
await callback.answer(
texts.t(
'SUBSCRIPTION_REVOKE_COOLDOWN',
'⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.',
).format(minutes=minutes, seconds=seconds),
show_alert=True,
)
return
await callback.answer()
await callback.message.edit_text(
texts.t(
'SUBSCRIPTION_REVOKE_WARNING',
(
'⚠️ <b>Перевыпуск подписки</b>\n\n'
'Это действие:\n'
'• Сгенерирует новую ссылку подключения\n'
'• Сбросит все подключённые устройства\n'
'• Старая ссылка перестанет работать\n\n'
'Продолжить?'
),
),
reply_markup=_build_revoke_confirm_keyboard(db_user.language, multi_tariff=False),
parse_mode='HTML',
)
@error_handler
async def confirm_subscription_revoke(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext | None = None,
) -> None:
"""Execute revoke for classic or multi-tariff mode (uses FSM state for multi)."""
if isinstance(callback.message, InaccessibleMessage):
await callback.answer()
return
texts = get_texts(db_user.language)
if not settings.is_subscription_revoke_enabled():
await callback.answer(
texts.t('SUBSCRIPTION_REVOKE_DISABLED', 'Перевыпуск подписки недоступен'),
show_alert=True,
)
return
# Determine subscription: multi-tariff via FSM state or classic via db_user
is_multi = False
subscription: Subscription | None = None
if state:
data = await state.get_data()
revoke_sub_id = data.get('revoke_sub_id')
if revoke_sub_id is not None:
is_multi = True
subscription = await get_subscription_by_id_for_user(db, revoke_sub_id, db_user.id)
if not subscription:
await callback.answer(
texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
show_alert=True,
)
return
if subscription is None:
subscription = db_user.subscription
if not subscription or not subscription.is_active:
await callback.answer(
texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
show_alert=True,
)
return
# TOCTOU protection: re-check cooldown
remaining = _check_revoke_cooldown(subscription)
if remaining is not None:
minutes = remaining // 60
seconds = remaining % 60
await callback.answer(
texts.t(
'SUBSCRIPTION_REVOKE_COOLDOWN',
'⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.',
).format(minutes=minutes, seconds=seconds),
show_alert=True,
)
return
# Answer callback BEFORE heavy operation
await callback.answer()
# Execute revoke
sub_service = SubscriptionService()
new_url = await sub_service.revoke_subscription(db, subscription)
if not new_url:
await callback.message.edit_text(
texts.t('SUBSCRIPTION_REVOKE_ERROR', '❌ Ошибка при перевыпуске подписки. Попробуйте позже.'),
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')],
]
),
parse_mode='HTML',
)
return
# Update cooldown timestamp
subscription.last_revoke_at = datetime.now(UTC)
await db.commit()
logger.info(
'Subscription revoked successfully',
user_id=db_user.id,
subscription_id=subscription.id,
is_multi=is_multi,
)
# Clean up FSM state
if state and is_multi:
await state.update_data(revoke_sub_id=None)
await callback.message.edit_text(
texts.t(
'SUBSCRIPTION_REVOKE_SUCCESS',
(
'✅ <b>Подписка перевыпущена!</b>\n\n'
'Новая ссылка подключения готова. '
'Старая ссылка больше не действительна.\n\n'
'Все устройства были отключены.'
),
),
reply_markup=_build_revoke_success_keyboard(db_user.language, multi_tariff=is_multi),
parse_mode='HTML',
)
# ---------------------------------------------------------------------------
# Multi-tariff mode
# ---------------------------------------------------------------------------
@error_handler
async def start_multi_revoke(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
) -> None:
"""Show revoke confirmation for multi-tariff mode (callback_data = 'sr:{sub_id}')."""
if isinstance(callback.message, InaccessibleMessage):
await callback.answer()
return
texts = get_texts(db_user.language)
if not settings.is_subscription_revoke_enabled():
await callback.answer(
texts.t('SUBSCRIPTION_REVOKE_DISABLED', 'Перевыпуск подписки недоступен'),
show_alert=True,
)
return
# Extract sub_id from callback_data
parts = (callback.data or '').split(':')
if len(parts) < 2:
await callback.answer('Неверный формат', show_alert=True)
return
try:
sub_id = int(parts[1])
except (ValueError, TypeError):
await callback.answer('Неверный формат', show_alert=True)
return
# Validate ownership (IDOR protection)
subscription = await get_subscription_by_id_for_user(db, sub_id, db_user.id)
if not subscription:
await callback.answer(
texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
show_alert=True,
)
return
if not subscription.is_active:
await callback.answer(
texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
show_alert=True,
)
return
# Check cooldown
remaining = _check_revoke_cooldown(subscription)
if remaining is not None:
minutes = remaining // 60
seconds = remaining % 60
await callback.answer(
texts.t(
'SUBSCRIPTION_REVOKE_COOLDOWN',
'⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.',
).format(minutes=minutes, seconds=seconds),
show_alert=True,
)
return
# Store sub_id in FSM state for the confirmation handler
await state.update_data(revoke_sub_id=sub_id)
await callback.answer()
await callback.message.edit_text(
texts.t(
'SUBSCRIPTION_REVOKE_WARNING',
(
'⚠️ <b>Перевыпуск подписки</b>\n\n'
'Это действие:\n'
'• Сгенерирует новую ссылку подключения\n'
'• Сбросит все подключённые устройства\n'
'• Старая ссылка перестанет работать\n\n'
'Продолжить?'
),
),
reply_markup=_build_revoke_confirm_keyboard(db_user.language, multi_tariff=True),
parse_mode='HTML',
)
+317 -38
View File
@@ -939,6 +939,13 @@ async def handle_custom_confirm(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
# Save promo offer state before deduction (for restore on failure)
@@ -958,11 +965,17 @@ async def handle_custom_confirm(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
except Exception as e:
logger.error('Ошибка списания баланса при покупке кастомного тарифа', error=e, exc_info=True)
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из тарифа
@@ -1049,7 +1062,10 @@ async def handle_custom_confirm(
price_kopeks=total_price,
refund_error=refund_error,
)
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки')
except Exception:
pass
return
try:
@@ -1148,11 +1164,12 @@ async def handle_custom_confirm(
),
parse_mode='HTML',
)
await callback.answer('Подписка оформлена!', show_alert=True)
except Exception as e:
logger.error('Ошибка при покупке тарифа с кастомными параметрами', error=e, exc_info=True)
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки')
except Exception:
pass
@error_handler
@@ -1377,6 +1394,13 @@ async def confirm_tariff_purchase(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
# Списываем баланс
@@ -1395,11 +1419,17 @@ async def confirm_tariff_purchase(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
except Exception as e:
logger.error('Ошибка списания баланса при покупке тарифа', error=e, exc_info=True)
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из тарифа
@@ -1457,10 +1487,12 @@ async def confirm_tariff_purchase(
db_user.promo_offer_discount_source = saved_promo_source
db_user.promo_offer_discount_expires_at = saved_promo_expires
await db.commit()
await callback.answer(
f'Максимум подписок: {settings.get_max_active_subscriptions()}',
show_alert=True,
)
try:
await callback.message.edit_text(
f'❌ Максимум подписок: {settings.get_max_active_subscriptions()}'
)
except Exception:
pass
return
# Create NEW subscription for this tariff (multi-tariff: new Remnawave user)
@@ -1537,7 +1569,10 @@ async def confirm_tariff_purchase(
reason='Возврат: тариф уже активен',
error=refund_error,
)
await callback.answer('У вас уже есть активная подписка на этот тариф', show_alert=True)
try:
await callback.message.edit_text('❌ У вас уже есть активная подписка на этот тариф')
except Exception:
pass
return
except Exception as e:
logger.error('Ошибка создания/продления подписки при покупке тарифа', error=e, exc_info=True)
@@ -1581,7 +1616,10 @@ async def confirm_tariff_purchase(
reason='Возврат: ошибка покупки тарифа',
error=refund_error,
)
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки')
except Exception:
pass
return
# Обновляем пользователя в Remnawave
@@ -1686,7 +1724,6 @@ async def confirm_tariff_purchase(
),
parse_mode='HTML',
)
await callback.answer('Подписка оформлена!', show_alert=True)
# ==================== Покупка суточного тарифа ====================
@@ -1741,6 +1778,13 @@ async def confirm_daily_tariff_purchase(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
try:
@@ -1754,11 +1798,17 @@ async def confirm_daily_tariff_purchase(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
except Exception as e:
logger.error('Ошибка списания баланса при покупке суточного тарифа', error=e, exc_info=True)
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из тарифа
@@ -1864,7 +1914,10 @@ async def confirm_daily_tariff_purchase(
price_kopeks=final_daily_price,
refund_error=refund_error,
)
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки')
except Exception:
pass
return
# Обновляем пользователя в Remnawave
@@ -1964,7 +2017,6 @@ async def confirm_daily_tariff_purchase(
),
parse_mode='HTML',
)
await callback.answer('Подписка оформлена!', show_alert=True)
# ==================== Продление по тарифу ====================
@@ -2105,8 +2157,36 @@ async def show_tariff_extend(
subscription = None
else:
subscription = await get_subscription_by_user_id(db, db_user.id)
if not subscription or not subscription.tariff_id:
await callback.answer('Тариф не найден', show_alert=True)
if not subscription:
await callback.answer('Подписка не найдена', show_alert=True)
return
if not subscription.tariff_id:
# Legacy user without tariff — show tariff selection for upgrade
promo_group_id = getattr(db_user, 'promo_group_id', None)
tariffs = await get_tariffs_for_user(db, promo_group_id)
if not tariffs:
await callback.answer('Нет доступных тарифов', show_alert=True)
return
keyboard = []
for t in tariffs:
if t.is_daily:
continue
keyboard.append([InlineKeyboardButton(text=f'📦 {t.name}', callback_data=f'tariff_select:{t.id}')])
if not keyboard:
await callback.answer('Нет доступных тарифов для продления', show_alert=True)
return
keyboard.append([InlineKeyboardButton(text='◀️ Назад', callback_data='back_to_menu')])
await callback.message.edit_text(
'🔄 <b>Выберите тариф для продления</b>\n\n'
'Для продления подписки необходимо выбрать тариф.\n'
'Подписка будет обновлена с параметрами выбранного тарифа.',
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode='HTML',
)
await callback.answer()
return
tariff = await get_tariff_by_id(db, subscription.tariff_id)
@@ -2114,6 +2194,31 @@ async def show_tariff_extend(
await callback.answer('Тариф не найден', show_alert=True)
return
# Скрытый/неактивный тариф (например, триальный после промокода) —
# показываем список доступных тарифов вместо продления скрытого
if not tariff.is_active:
promo_group_id = getattr(db_user, 'promo_group_id', None)
tariffs = await get_tariffs_for_user(db, promo_group_id)
active_tariffs = [t for t in tariffs if not t.is_daily]
if not active_tariffs:
await callback.answer('Нет доступных тарифов для продления', show_alert=True)
return
keyboard = []
for t in active_tariffs:
keyboard.append([InlineKeyboardButton(text=f'📦 {t.name}', callback_data=f'tariff_select:{t.id}')])
keyboard.append([InlineKeyboardButton(text='◀️ Назад', callback_data='back_to_menu')])
await callback.message.edit_text(
'🔄 <b>Выберите тариф для продления</b>\n\n'
'Для продления подписки необходимо выбрать тариф.\n'
'Подписка будет обновлена с параметрами выбранного тарифа.',
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode='HTML',
)
await callback.answer()
return
traffic = format_traffic(tariff.traffic_limit_gb)
# Проверяем есть ли у пользователя скидки по периодам
@@ -2311,6 +2416,13 @@ async def confirm_tariff_extend(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
try:
@@ -2324,7 +2436,10 @@ async def confirm_tariff_extend(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Запоминаем, был ли триал ДО продления
@@ -2434,11 +2549,12 @@ async def confirm_tariff_extend(
),
parse_mode='HTML',
)
await callback.answer('Подписка продлена!', show_alert=True)
except Exception as e:
logger.error('Ошибка при продлении тарифа', error=e, exc_info=True)
await callback.answer('Произошла ошибка при продлении подписки', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при продлении подписки')
except Exception:
pass
# ==================== Переключение тарифов ====================
@@ -2613,6 +2729,18 @@ async def show_tariff_switch_list(
current_tariff_id = subscription.tariff_id
# Проверяем, разрешена ли смена тарифа хотя бы в одном направлении
if not settings.TARIFF_SWITCH_UPGRADE_ENABLED and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
await callback.message.edit_text(
'🚫 <b>Смена тарифа недоступна</b>\n\nАдминистратор отключил возможность смены тарифа.',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]]
),
parse_mode='HTML',
)
await callback.answer()
return
# Получаем доступные тарифы
promo_group_id = getattr(db_user, 'promo_group_id', None)
tariffs = await get_tariffs_for_user(db, promo_group_id)
@@ -2625,6 +2753,14 @@ async def show_tariff_switch_list(
else:
available_tariffs = [t for t in tariffs if t.id != current_tariff_id]
# Фильтруем по разрешённым направлениям (upgrade/downgrade)
current_tariff = await get_tariff_by_id(db, current_tariff_id) if current_tariff_id else None
if current_tariff:
remaining_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0
available_tariffs = _filter_tariffs_by_switch_direction(
available_tariffs, current_tariff, remaining_days, db_user
)
if not available_tariffs:
await callback.message.edit_text(
'😔 <b>Нет доступных тарифов для переключения</b>\n\nВы уже используете единственный доступный тариф.',
@@ -2686,6 +2822,24 @@ async def select_tariff_switch(
await callback.answer('Тариф недоступен', show_alert=True)
return
# Проверяем разрешение на смену в данном направлении
current_subscription_sw, _sw_sub_id_check = await _resolve_subscription(callback, db_user, db, state)
if current_subscription_sw and current_subscription_sw.tariff_id:
cur_tariff_sw = await get_tariff_by_id(db, current_subscription_sw.tariff_id)
if cur_tariff_sw:
rem_days = (
max(0, (current_subscription_sw.end_date - datetime.now(UTC)).days)
if current_subscription_sw.end_date
else 0
)
_, is_up = _calculate_instant_switch_cost(cur_tariff_sw, tariff, rem_days, db_user)
if is_up and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
await callback.answer('Повышение тарифа недоступно', show_alert=True)
return
if not is_up and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
await callback.answer('Понижение тарифа недоступно', show_alert=True)
return
traffic = format_traffic(tariff.traffic_limit_gb)
# Проверяем, суточный ли это тариф
@@ -2907,6 +3061,19 @@ async def confirm_tariff_switch(
await callback.answer('У вас нет активной подписки', show_alert=True)
return
# Проверяем разрешение на смену в данном направлении
if subscription.tariff_id and subscription.tariff_id != tariff_id:
cur_tariff_obj = await get_tariff_by_id(db, subscription.tariff_id)
if cur_tariff_obj:
rem_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0
_, is_up = _calculate_instant_switch_cost(cur_tariff_obj, tariff, rem_days, db_user)
if is_up and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
await callback.answer('Повышение тарифа недоступно', show_alert=True)
return
if not is_up and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
await callback.answer('Понижение тарифа недоступно', show_alert=True)
return
# Calculate price via PricingEngine (handles per-category discounts + extra devices)
from app.services.pricing_engine import pricing_engine
@@ -2927,6 +3094,13 @@ async def confirm_tariff_switch(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
try:
@@ -2940,7 +3114,10 @@ async def confirm_tariff_switch(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из тарифа
@@ -3094,11 +3271,13 @@ async def confirm_tariff_switch(
),
parse_mode='HTML',
)
await callback.answer('Тариф изменён!', show_alert=True)
except Exception as e:
logger.error('Ошибка при переключении тарифа', error=e, exc_info=True)
await callback.answer('Произошла ошибка при переключении тарифа', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при переключении тарифа')
except Exception:
pass
# ==================== Смена на суточный тариф ====================
@@ -3159,6 +3338,26 @@ async def confirm_daily_tariff_switch(
await callback.answer('У вас нет активной подписки', show_alert=True)
return
# Проверяем разрешение на смену в данном направлении
if subscription.tariff_id and subscription.tariff_id != tariff_id:
cur_tariff_daily = await get_tariff_by_id(db, subscription.tariff_id)
if cur_tariff_daily:
rem_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0
_, is_up = _calculate_instant_switch_cost(cur_tariff_daily, tariff, rem_days, db_user)
if is_up and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
await callback.answer('Повышение тарифа недоступно', show_alert=True)
return
if not is_up and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
await callback.answer('Понижение тарифа недоступно', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
try:
@@ -3172,7 +3371,10 @@ async def confirm_daily_tariff_switch(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из тарифа
@@ -3328,7 +3530,6 @@ async def confirm_daily_tariff_switch(
),
parse_mode='HTML',
)
await callback.answer('Тариф изменён!', show_alert=True)
except Exception as e:
logger.error('Ошибка при смене на суточный тариф', error=e, exc_info=True)
@@ -3361,7 +3562,10 @@ async def confirm_daily_tariff_switch(
price_kopeks=final_daily_price,
refund_error=refund_error,
)
await callback.answer('Произошла ошибка при смене тарифа', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при смене тарифа')
except Exception:
pass
# ==================== Мгновенное переключение тарифов (без выбора периода) ====================
@@ -3390,6 +3594,30 @@ def _calculate_instant_switch_cost(
return result.upgrade_cost, result.is_upgrade
def _filter_tariffs_by_switch_direction(
tariffs: list[Tariff],
current_tariff: Tariff,
remaining_days: int,
db_user: User | None = None,
) -> list[Tariff]:
"""Фильтрует тарифы по разрешённым направлениям смены (upgrade/downgrade)."""
upgrade_ok = settings.TARIFF_SWITCH_UPGRADE_ENABLED
downgrade_ok = settings.TARIFF_SWITCH_DOWNGRADE_ENABLED
if upgrade_ok and downgrade_ok:
return tariffs
filtered = []
for tariff in tariffs:
if tariff.id == current_tariff.id:
filtered.append(tariff)
continue
_, is_upgrade = _calculate_instant_switch_cost(current_tariff, tariff, remaining_days, db_user)
if (is_upgrade and upgrade_ok) or (not is_upgrade and downgrade_ok):
filtered.append(tariff)
return filtered
def format_instant_switch_list_text(
tariffs: list[Tariff],
current_tariff: Tariff,
@@ -3397,16 +3625,21 @@ def format_instant_switch_list_text(
db_user: User | None = None,
) -> str:
"""Форматирует текст со списком тарифов для мгновенного переключения."""
upgrade_ok = settings.TARIFF_SWITCH_UPGRADE_ENABLED
downgrade_ok = settings.TARIFF_SWITCH_DOWNGRADE_ENABLED
lines = [
'📦 <b>Мгновенная смена тарифа</b>',
f'📌 Текущий: <b>{html.escape(current_tariff.name)}</b>',
f'⏰ Осталось: <b>{remaining_days} дн.</b>',
'',
'💡 При переключении остаток дней сохраняется.',
'⬆️ Повышение тарифа = доплата за разницу',
'⬇️ Понижение = бесплатно',
'',
]
if upgrade_ok:
lines.append('⬆️ Повышение тарифа = доплата за разницу')
if downgrade_ok:
lines.append('⬇️ Понижение = бесплатно')
lines.append('')
for tariff in tariffs:
if tariff.id == current_tariff.id:
@@ -3538,6 +3771,18 @@ async def show_instant_switch_list(
await callback.answer()
return
# Проверяем, разрешена ли смена тарифа хотя бы в одном направлении
if not settings.TARIFF_SWITCH_UPGRADE_ENABLED and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
await callback.message.edit_text(
'🚫 <b>Смена тарифа недоступна</b>\n\nАдминистратор отключил возможность смены тарифа.',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]]
),
parse_mode='HTML',
)
await callback.answer()
return
# Получаем доступные тарифы
promo_group_id = getattr(db_user, 'promo_group_id', None)
tariffs = await get_tariffs_for_user(db, promo_group_id)
@@ -3550,6 +3795,9 @@ async def show_instant_switch_list(
else:
available_tariffs = [t for t in tariffs if t.id != current_tariff.id]
# Фильтруем по разрешённым направлениям (upgrade/downgrade)
available_tariffs = _filter_tariffs_by_switch_direction(available_tariffs, current_tariff, remaining_days, db_user)
if not available_tariffs:
await callback.message.edit_text(
'😔 <b>Нет доступных тарифов для переключения</b>\n\nВы уже используете единственный доступный тариф.',
@@ -3619,6 +3867,14 @@ async def preview_instant_switch(
# Рассчитываем стоимость переключения
upgrade_cost, is_upgrade = _calculate_instant_switch_cost(current_tariff, new_tariff, remaining_days, db_user)
# Проверяем разрешение на смену в данном направлении
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
await callback.answer('Повышение тарифа недоступно', show_alert=True)
return
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
await callback.answer('Понижение тарифа недоступно', show_alert=True)
return
# Проверяем баланс
user_balance = db_user.balance_kopeks or 0
@@ -3793,12 +4049,27 @@ async def confirm_instant_switch(
is_upgrade = switch_result.is_upgrade
consume_promo = switch_result.offer_discount_pct > 0
# Проверяем разрешение на смену в данном направлении
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
await callback.answer('Повышение тарифа недоступно', show_alert=True)
return
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
await callback.answer('Понижение тарифа недоступно', show_alert=True)
return
# Проверяем баланс если это upgrade (use locked user's fresh balance)
user_balance = db_user.balance_kopeks or 0
if is_upgrade and user_balance < upgrade_cost:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции),
# иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest
try:
await callback.answer()
except Exception:
pass
texts = get_texts(db_user.language)
try:
@@ -3814,7 +4085,10 @@ async def confirm_instant_switch(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('Ошибка списания баланса', show_alert=True)
try:
await callback.message.edit_text('❌ Ошибка списания баланса')
except Exception:
pass
return
# Получаем список серверов из нового тарифа
@@ -3881,7 +4155,10 @@ async def confirm_instant_switch(
mark_as_paid_subscription=True,
)
if not success:
await callback.answer('❌ Недостаточно средств', show_alert=True)
try:
await callback.message.edit_text('❌ Недостаточно средств')
except Exception:
pass
return
await create_transaction(
db,
@@ -4055,11 +4332,13 @@ async def confirm_instant_switch(
),
parse_mode='HTML',
)
await callback.answer('Тариф изменён!', show_alert=True)
except Exception as e:
logger.error('Ошибка при мгновенном переключении тарифа', error=e, exc_info=True)
await callback.answer('Произошла ошибка при переключении тарифа', show_alert=True)
try:
await callback.message.edit_text('❌ Произошла ошибка при переключении тарифа')
except Exception:
pass
async def return_to_saved_tariff_cart(
+4 -3
View File
@@ -1,3 +1,4 @@
import math
from datetime import UTC, datetime
from aiogram import types
@@ -807,7 +808,7 @@ async def confirm_switch_traffic(
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
now = datetime.now(UTC)
days_remaining = max(1, (subscription.end_date - now).days)
days_remaining = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_remaining if days_remaining > 0 else None
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
@@ -911,7 +912,7 @@ async def execute_switch_traffic(
base_traffic = current_traffic - purchased_traffic
old_price_per_month = settings.get_traffic_price(base_traffic)
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'traffic',
@@ -936,7 +937,7 @@ async def execute_switch_traffic(
await callback.answer('⚠️ Ошибка списания средств', show_alert=True)
return
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
await create_transaction(
db=db,
user_id=db_user.id,
+303 -12
View File
@@ -1,3 +1,4 @@
import math
from datetime import UTC, datetime
import structlog
@@ -7,6 +8,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import PERIOD_PRICES, settings
from app.database.models import User
from app.handlers.subscription.common import (
build_redirect_link,
create_deep_link,
get_localized_value,
resolve_button_url,
)
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
@@ -1831,6 +1838,264 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_overpay_enabled():
overpay_name = settings.get_overpay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_OVERPAY', f'💳 {overpay_name}'),
callback_data=_build_callback('overpay'),
)
]
)
has_direct_payment_methods = True
if settings.is_aurapay_sbp_enabled():
sbp_name = settings.get_aurapay_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_AURAPAY_SBP', f'📱 {sbp_name}'),
callback_data=_build_callback('aurapay_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_aurapay_card_enabled():
card_name = settings.get_aurapay_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_AURAPAY_CARD', f'💳 {card_name}'),
callback_data=_build_callback('aurapay_card'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_aurapay_enabled()
and not settings.is_aurapay_sbp_enabled()
and not settings.is_aurapay_card_enabled()
):
aurapay_name = settings.get_aurapay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_AURAPAY', f'💳 {aurapay_name}'),
callback_data=_build_callback('aurapay'),
)
]
)
has_direct_payment_methods = True
if settings.is_etoplatezhi_sbp_enabled():
sbp_name = settings.get_etoplatezhi_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ETOPLATEZHI_SBP', f'📱 {sbp_name}'),
callback_data=_build_callback('etoplatezhi_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_etoplatezhi_card_enabled():
card_name = settings.get_etoplatezhi_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ETOPLATEZHI_CARD', f'💳 {card_name}'),
callback_data=_build_callback('etoplatezhi_card'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_etoplatezhi_enabled()
and not settings.is_etoplatezhi_sbp_enabled()
and not settings.is_etoplatezhi_card_enabled()
):
etoplatezhi_name = settings.get_etoplatezhi_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ETOPLATEZHI', f'💳 {etoplatezhi_name}'),
callback_data=_build_callback('etoplatezhi'),
)
]
)
has_direct_payment_methods = True
if settings.is_antilopay_sbp_enabled():
sbp_name = settings.get_antilopay_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ANTILOPAY_SBP', f'📱 {sbp_name}'),
callback_data=_build_callback('antilopay_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_antilopay_card_enabled():
card_name = settings.get_antilopay_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ANTILOPAY_CARD', f'💳 {card_name}'),
callback_data=_build_callback('antilopay_card'),
)
]
)
has_direct_payment_methods = True
if settings.is_antilopay_sberpay_enabled():
sberpay_name = settings.get_antilopay_sberpay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ANTILOPAY_SBERPAY', f'💳 {sberpay_name}'),
callback_data=_build_callback('antilopay_sberpay'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_antilopay_enabled()
and not settings.is_antilopay_sbp_enabled()
and not settings.is_antilopay_card_enabled()
and not settings.is_antilopay_sberpay_enabled()
):
antilopay_name = settings.get_antilopay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ANTILOPAY', f'💳 {antilopay_name}'),
callback_data=_build_callback('antilopay'),
)
]
)
has_direct_payment_methods = True
if settings.is_jupiter_sbp_enabled():
jupiter_sbp_name = settings.get_jupiter_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_JUPITER_SBP', f'📱 {jupiter_sbp_name}'),
callback_data=_build_callback('jupiter_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_jupiter_enabled() and not settings.is_jupiter_sbp_enabled():
jupiter_name = settings.get_jupiter_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_JUPITER', f'🪐 {jupiter_name}'),
callback_data=_build_callback('jupiter'),
)
]
)
has_direct_payment_methods = True
if settings.is_donut_card_enabled():
donut_card_name = settings.get_donut_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_DONUT_CARD', f'💳 {donut_card_name}'),
callback_data=_build_callback('donut_card'),
)
]
)
has_direct_payment_methods = True
if settings.is_donut_sbp_enabled():
donut_sbp_name = settings.get_donut_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_DONUT_SBP', f'📱 {donut_sbp_name}'),
callback_data=_build_callback('donut_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_donut_sbp_qr_enabled():
donut_qr_name = settings.get_donut_sbp_qr_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_DONUT_SBP_QR', f'🏦 {donut_qr_name}'),
callback_data=_build_callback('donut_sbp_qr'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_donut_enabled()
and not settings.is_donut_card_enabled()
and not settings.is_donut_sbp_enabled()
and not settings.is_donut_sbp_qr_enabled()
):
donut_name = settings.get_donut_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_DONUT', f'🍩 {donut_name}'),
callback_data=_build_callback('donut'),
)
]
)
has_direct_payment_methods = True
if settings.is_lava_card_enabled():
lava_card_name = settings.get_lava_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_LAVA_CARD', f'💳 {lava_card_name}'),
callback_data=_build_callback('lava_card'),
)
]
)
has_direct_payment_methods = True
if settings.is_lava_sbp_enabled():
lava_sbp_name = settings.get_lava_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_LAVA_SBP', f'📱 {lava_sbp_name}'),
callback_data=_build_callback('lava_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_lava_enabled() and not settings.is_lava_card_enabled() and not settings.is_lava_sbp_enabled():
lava_name = settings.get_lava_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_LAVA', f'🌋 {lava_name}'),
callback_data=_build_callback('lava'),
)
]
)
has_direct_payment_methods = True
if settings.is_support_topup_enabled():
keyboard.append(
[
@@ -2145,7 +2410,7 @@ def get_add_traffic_keyboard(
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
price_multiplier = days_left / 30
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
else:
@@ -2287,7 +2552,7 @@ def get_change_devices_keyboard(
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
price_multiplier = days_left / 30
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
else:
@@ -2298,8 +2563,8 @@ def get_change_devices_keyboard(
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
if tariff and tariff_device_price:
device_price_per_month = tariff_device_price
# Для тарифов все устройства платные (нет бесплатного лимита)
default_device_limit = 0
# Устройства в пределах тарифного лимита — бесплатные
default_device_limit = tariff.device_limit if tariff else 0
else:
device_price_per_month = settings.PRICE_PER_DEVICE
default_device_limit = settings.DEFAULT_DEVICE_LIMIT
@@ -2449,7 +2714,7 @@ def get_manage_countries_keyboard(
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
price_multiplier = days_left / 30
logger.info(
'🔍 Расчет для управления странами: осталось дней до',
@@ -2529,9 +2794,6 @@ def get_device_selection_keyboard(
platforms: list[dict] | None = None,
sub_id: int | None = None,
) -> InlineKeyboardMarkup:
from app.config import settings
from app.handlers.subscription.common import get_localized_value
texts = get_texts(language)
back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription'
@@ -2584,8 +2846,6 @@ def get_connection_guide_keyboard(
has_other_apps: bool = False,
sub_id: int | None = None,
) -> InlineKeyboardMarkup:
from app.handlers.subscription.common import create_deep_link, get_localized_value, resolve_button_url
texts = get_texts(language)
back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription'
@@ -2598,6 +2858,10 @@ def get_connection_guide_keyboard(
if not isinstance(btn, dict):
continue
btn_type = btn.get('type', '')
# Support both 'external' and 'externalLink' for backward compatibility
if btn_type == 'external':
btn_type = 'externalLink'
btn_text = btn.get('text', {})
if isinstance(btn_text, dict):
btn_text = get_localized_value(btn_text, language)
@@ -2619,9 +2883,26 @@ def get_connection_guide_keyboard(
]
)
elif btn_type == 'subscriptionLink':
# First try to resolve the button's URL template
url = resolved_url or resolve_button_url(btn_url, subscription_url)
deep_link = create_deep_link(app.get('_raw', app), subscription_url)
final_url = deep_link or url or subscription_url
# If button has no template, try deep link
if not btn_url or '{{SUBSCRIPTION_LINK}}' not in btn_url:
deep_link = create_deep_link(app.get('_raw', app), subscription_url)
final_url = deep_link or url or subscription_url
else:
final_url = url or subscription_url
# Telegram doesn't support custom URL schemes — wrap with redirect
if final_url and not final_url.startswith(('http://', 'https://')):
template = settings.get_happ_cryptolink_redirect_template()
if template:
wrapped_url = build_redirect_link(final_url, template)
if wrapped_url:
final_url = wrapped_url
else:
final_url = subscription_url
if final_url:
keyboard.append(
[
@@ -2951,6 +3232,16 @@ def get_updated_subscription_settings_keyboard(
]
)
if settings.is_subscription_revoke_enabled():
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('SUBSCRIPTION_REVOKE_BTN', '🔄 Перевыпустить подписку'),
callback_data='subscription_revoke',
)
]
)
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
+11 -1
View File
@@ -1758,5 +1758,15 @@
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Torrent detected</b>\n\nTorrent traffic was detected on your connection{tariff_label}. Using torrents may result in subscription restrictions.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Close",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Traffic Warning</b>\n\nUsed: {used:.1f} / {limit} GB ({percent:.0f}%)\n\nYour traffic limit is almost reached.",
"LOW_BALANCE_ALERT": "⚠️ <b>Low Balance</b>\n\nYour balance: {balance} ₽\nNotification threshold: {threshold} ₽\n\nTop up your balance to ensure automatic subscription renewal."
"LOW_BALANCE_ALERT": "⚠️ <b>Low Balance</b>\n\nYour balance: {balance} ₽\nNotification threshold: {threshold} ₽\n\nTop up your balance to ensure automatic subscription renewal.",
"SUBSCRIPTION_REVOKE_BTN": "🔄 Reissue Subscription",
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ Reissue Subscription",
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>Reissue Subscription</b>\n\nThis action will:\n• Generate a new connection link\n• Disconnect all devices\n• The old link will stop working\n\nContinue?",
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Confirm",
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>Subscription reissued!</b>\n\nYour new connection link is ready. The old link is no longer valid.\n\nAll devices have been disconnected.",
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Reissue will be available in {minutes} min {seconds} sec.",
"SUBSCRIPTION_REVOKE_DISABLED": "Subscription reissue is not available",
"SUBSCRIPTION_REVOKE_ERROR": "❌ Error reissuing subscription. Please try again later.",
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Connect"
}
+11 -1
View File
@@ -1779,5 +1779,15 @@
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>تورنت شناسایی شد</b>\n\nترافیک تورنت در اتصال{tariff_label} شما شناسایی شد. استفاده از تورنت ممکن است منجر به محدودیت اشتراک شود.",
"WEBHOOK_CLOSE_BUTTON": "✖️ بستن",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>هشدار ترافیک</b>\n\nاستفاده شده: {used:.1f} / {limit} گیگابایت ({percent:.0f}%)\n\nحد ترافیک شما تقریباً تمام شده است.",
"LOW_BALANCE_ALERT": "⚠️ <b>موجودی کم</b>\n\nموجودی شما: {balance} ₽\nآستانه اطلاع‌رسانی: {threshold} ₽\n\nموجودی خود را شارژ کنید تا تمدید خودکار اشتراک با موفقیت انجام شود."
"LOW_BALANCE_ALERT": "⚠️ <b>موجودی کم</b>\n\nموجودی شما: {balance} ₽\nآستانه اطلاع‌رسانی: {threshold} ₽\n\nموجودی خود را شارژ کنید تا تمدید خودکار اشتراک با موفقیت انجام شود.",
"SUBSCRIPTION_REVOKE_BTN": "🔄 صدور مجدد اشتراک",
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ صدور مجدد اشتراک",
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>صدور مجدد اشتراک</b>\n\nاین عمل:\n• لینک اتصال جدیدی تولید می‌کند\n• تمام دستگاه‌های متصل را قطع می‌کند\n• لینک قدیمی دیگر کار نخواهد کرد\n\nادامه می‌دهید؟",
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ تأیید",
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>اشتراک مجدداً صادر شد!</b>\n\nلینک اتصال جدید آماده است. لینک قدیمی دیگر معتبر نیست.\n\nتمام دستگاه‌ها قطع شدند.",
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ صدور مجدد {minutes} دقیقه و {seconds} ثانیه دیگر در دسترس خواهد بود.",
"SUBSCRIPTION_REVOKE_DISABLED": "صدور مجدد اشتراک در دسترس نیست",
"SUBSCRIPTION_REVOKE_ERROR": "❌ خطا در صدور مجدد اشتراک. لطفاً بعداً دوباره امتحان کنید.",
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 اتصال"
}
+11 -1
View File
@@ -1779,5 +1779,15 @@
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Обнаружен торрент</b>\n\nВ вашем подключении{tariff_label} обнаружен торрент-трафик. Использование торрентов может привести к ограничению подписки.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Предупреждение о трафике</b>\n\nИспользовано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш лимит трафика почти исчерпан.",
"LOW_BALANCE_ALERT": "⚠️ <b>Низкий баланс</b>\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно."
"LOW_BALANCE_ALERT": "⚠️ <b>Низкий баланс</b>\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно.",
"SUBSCRIPTION_REVOKE_BTN": "🔄 Перевыпустить подписку",
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ Перевыпуск подписки",
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>Перевыпуск подписки</b>\n\nЭто действие:\n• Сгенерирует новую ссылку подключения\n• Сбросит все подключённые устройства\n• Старая ссылка перестанет работать\n\nПродолжить?",
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Подтвердить",
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>Подписка перевыпущена!</b>\n\nНовая ссылка подключения готова. Старая ссылка больше не действительна.\n\nВсе устройства были отключены.",
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.",
"SUBSCRIPTION_REVOKE_DISABLED": "Перевыпуск подписки недоступен",
"SUBSCRIPTION_REVOKE_ERROR": "❌ Ошибка при перевыпуске подписки. Попробуйте позже.",
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Подключиться"
}
+11 -1
View File
@@ -1650,5 +1650,15 @@
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Виявлено торент</b>\n\nУ вашому підключенні{tariff_label} виявлено торент-трафік. Використання торентів може призвести до обмеження підписки.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрити",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Попередження про трафік</b>\n\nВикористано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш ліміт трафіку майже вичерпаний.",
"LOW_BALANCE_ALERT": "⚠️ <b>Низький баланс</b>\n\nВаш баланс: {balance} ₽\nПоріг сповіщення: {threshold} ₽\n\nПоповніть баланс, щоб автопродовження підписки пройшло успішно."
"LOW_BALANCE_ALERT": "⚠️ <b>Низький баланс</b>\n\nВаш баланс: {balance} ₽\nПоріг сповіщення: {threshold} ₽\n\nПоповніть баланс, щоб автопродовження підписки пройшло успішно.",
"SUBSCRIPTION_REVOKE_BTN": "🔄 Перевипустити підписку",
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ Перевипуск підписки",
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>Перевипуск підписки</b>\n\nЦя дія:\n• Згенерує нове посилання підключення\n• Скине всі підключені пристрої\n• Старе посилання перестане працювати\n\nПродовжити?",
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Підтвердити",
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>Підписку перевипущено!</b>\n\nНове посилання підключення готове. Старе посилання більше не дійсне.\n\nВсі пристрої були відключені.",
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Перевипуск буде доступний через {minutes} хв. {seconds} сек.",
"SUBSCRIPTION_REVOKE_DISABLED": "Перевипуск підписки недоступний",
"SUBSCRIPTION_REVOKE_ERROR": "❌ Помилка при перевипуску підписки. Спробуйте пізніше.",
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Підключитися"
}
+11 -1
View File
@@ -1648,5 +1648,15 @@
"BALANCE_TOPPED_UP_CART_SUFFICIENT": "✅ 余额已充值 {amount}\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n余额足够完成订购。",
"BALANCE_TOPPED_UP_CART_INSUFFICIENT": "✅ 余额已充值 {amount}\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n还差:{missing}",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>流量警告</b>\n\n已使用:{used:.1f} / {limit} GB ({percent:.0f}%)\n\n您的流量限制即将用完。",
"LOW_BALANCE_ALERT": "⚠️ <b>余额不足</b>\n\n您的余额:{balance} ₽\n通知阈值:{threshold} ₽\n\n请充值以确保订阅自动续费成功。"
"LOW_BALANCE_ALERT": "⚠️ <b>余额不足</b>\n\n您的余额:{balance} ₽\n通知阈值:{threshold} ₽\n\n请充值以确保订阅自动续费成功。",
"SUBSCRIPTION_REVOKE_BTN": "🔄 重新签发订阅",
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ 重新签发订阅",
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>重新签发订阅</b>\n\n此操作将:\n• 生成新的连接链接\n• 断开所有已连接的设备\n• 旧链接将失效\n\n是否继续?",
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ 确认",
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>订阅已重新签发!</b>\n\n新的连接链接已准备就绪。旧链接已失效。\n\n所有设备已断开连接。",
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ 重新签发将在 {minutes} 分 {seconds} 秒后可用。",
"SUBSCRIPTION_REVOKE_DISABLED": "订阅重新签发不可用",
"SUBSCRIPTION_REVOKE_ERROR": "❌ 重新签发订阅时出错。请稍后重试。",
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 连接"
}
+46
View File
@@ -15,6 +15,7 @@ Usage::
from __future__ import annotations
import logging
import sys
from typing import Any
import structlog
@@ -56,6 +57,46 @@ def _prefix_logger_name(logger: Any, method_name: str, event_dict: dict[str, Any
return event_dict
def _auto_capture_exc_info(logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
"""Auto-populate event_dict['exc_info'] so tracebacks render in files/console.
Without this, callers must pass ``exc_info=True`` at every ``logger.error``
site. Instead, we try:
1. exc_info=True replace with sys.exc_info() (standard structlog behaviour)
2. no exc_info but we're inside an active except block → use sys.exc_info()
3. error/exc/exception/e/err kwarg is a BaseException with __traceback__
synthesize an exc_info tuple from it
Result: ``logger.error('msg', error=e)`` inside any ``except`` block now
renders the full traceback to files, console, and Telegram automatically.
"""
exc_info = event_dict.get('exc_info')
if exc_info is True:
current = sys.exc_info()
if current[1] is not None:
event_dict['exc_info'] = current
return event_dict
if exc_info:
return event_dict
# Only auto-capture from sys.exc_info() for error/critical levels.
# For warning/info inside except blocks, callers must pass exc_info=True explicitly.
if method_name in ('error', 'critical', 'exception'):
current = sys.exc_info()
if current[1] is not None:
event_dict['exc_info'] = current
return event_dict
for key in ('error', 'exc', 'exception', 'e', 'err'):
candidate = event_dict.get(key)
if isinstance(candidate, BaseException) and candidate.__traceback__ is not None:
event_dict['exc_info'] = (type(candidate), candidate, candidate.__traceback__)
return event_dict
return event_dict
def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]:
"""Configure structlog and return formatters + notifier.
@@ -82,6 +123,11 @@ def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]:
structlog.stdlib.PositionalArgumentsFormatter(),
timestamper,
structlog.processors.StackInfoRenderer(),
# Auto-capture traceback from sys.exc_info()/error-kwarg BEFORE any
# consumer looks at event_dict. Runs for ALL log levels so files,
# console, and Telegram all see the same traceback without requiring
# every caller to pass exc_info=True.
_auto_capture_exc_info,
# TelegramNotifierProcessor MUST run while exc_info is still a raw
# tuple so it can extract the traceback for Telegram notifications.
# ConsoleRenderer handles exc_info formatting downstream (with Rich
+16 -1
View File
@@ -129,13 +129,28 @@ class TelegramNotifierProcessor:
if any(logger_name.startswith(prefix) for prefix in IGNORED_LOGGER_PREFIXES):
return event_dict
# 4. Resolve exc_info=True to actual tuple while still in except block.
# 4. Resolve exc_info into actual tuple while still in except block.
# logger.exception() sets exc_info=True (bool); we need the tuple for
# traceback extraction. sys.exc_info() works because the processor runs
# synchronously inside the except clause.
#
# If exc_info is not passed at all, auto-capture traceback from:
# (a) sys.exc_info() — works when logger.error is called inside except
# (b) error/exc/exception kwargs if they carry __traceback__
# This avoids having to pass exc_info=True at every logger.error site.
exc_info = event_dict.get('exc_info')
if exc_info is True:
event_dict['exc_info'] = sys.exc_info()
elif not exc_info:
current = sys.exc_info()
if current[1] is not None:
event_dict['exc_info'] = current
else:
for key in ('error', 'exc', 'exception', 'e', 'err'):
candidate = event_dict.get(key)
if isinstance(candidate, BaseException) and candidate.__traceback__ is not None:
event_dict['exc_info'] = (type(candidate), candidate, candidate.__traceback__)
break
# 5. Bot not initialized yet — skip
bot = self._bot
@@ -67,6 +67,12 @@ class AdminNotificationService:
NotificationCategory.TICKETS: self.ticket_topic_id,
}
# Per-category enabled flags (default True — backwards compatible)
self.category_enabled: dict[NotificationCategory, bool] = {}
for cat in NotificationCategory:
key = f'ADMIN_NOTIFICATIONS_{cat.value.upper()}_ENABLED'
self.category_enabled[cat] = getattr(settings, key, True)
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: int | None) -> str:
if not referred_by_id:
return 'Нет'
@@ -1266,6 +1272,11 @@ class AdminNotificationService:
logger.warning('ADMIN_NOTIFICATIONS_CHAT_ID не настроен')
return False
# Per-category suppression
if category and not self.category_enabled.get(category, True):
logger.debug('Уведомление подавлено (категория отключена)', category=category.value)
return False
try:
message_kwargs = {
'chat_id': self.chat_id,
+246
View File
@@ -0,0 +1,246 @@
"""Сервис для работы с API Antilopay (lk.antilopay.com/api/v2)."""
import base64
import json
from typing import Any
import aiohttp
import structlog
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from app.config import settings
logger = structlog.get_logger(__name__)
API_BASE_URL = 'https://lk.antilopay.com/api/v2'
class AntilopayAPIError(Exception):
"""Ошибка API Antilopay."""
def __init__(self, status_code: int, message: str, code: int | None = None):
self.status_code = status_code
self.message = message
self.api_code = code
super().__init__(f'Antilopay API error ({status_code}): {message}')
class AntilopayService:
"""Сервис для работы с API Antilopay."""
def __init__(self) -> None:
self._session: aiohttp.ClientSession | None = None
@property
def secret_id(self) -> str:
return settings.ANTILOPAY_SECRET_ID or ''
@property
def private_key(self) -> str:
return settings.ANTILOPAY_PRIVATE_KEY or ''
@property
def public_key(self) -> str:
return settings.ANTILOPAY_PUBLIC_KEY or ''
@property
def project_id(self) -> str:
return settings.ANTILOPAY_PROJECT_ID or ''
async def _get_session(self) -> aiohttp.ClientSession:
"""Возвращает переиспользуемую HTTP-сессию."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
)
return self._session
async def close(self) -> None:
"""Закрывает HTTP-сессию."""
if self._session and not self._session.closed:
await self._session.close()
self._session = None
def _sign_request(self, json_body: str) -> str:
"""SHA256WithRSA подпись JSON body приватным ключом.
Результат base64-encoded строка.
"""
rsa_key = RSA.import_key(base64.b64decode(self.private_key))
h = SHA256.new(json_body.encode('UTF-8'))
signature = pkcs1_15.new(rsa_key).sign(h)
return base64.b64encode(signature).decode('UTF-8')
def _build_headers(self, json_body: str) -> dict[str, str]:
"""Строит заголовки запроса с подписью."""
return {
'Content-Type': 'application/json',
'X-Apay-Secret-Id': self.secret_id,
'X-Apay-Sign': self._sign_request(json_body),
'X-Apay-Sign-Version': '1',
}
async def create_payment(
self,
*,
amount_rubles: float,
order_id: str,
product_name: str,
product_type: str = 'services',
description: str = '',
customer_email: str | None = None,
customer_phone: str | None = None,
prefer_methods: list[str] | None = None,
success_url: str | None = None,
fail_url: str | None = None,
merchant_extra: str | None = None,
) -> dict[str, Any]:
"""
Создает платеж через API Antilopay.
POST /payment/create
"""
payload: dict[str, Any] = {
'project_identificator': self.project_id,
'amount': amount_rubles,
'order_id': order_id,
'currency': settings.ANTILOPAY_CURRENCY.lower(),
'product_name': product_name,
'product_type': product_type,
'description': description,
}
# customer — обязательное поле, нужен email или phone
customer: dict[str, str] = {}
if customer_email:
customer['email'] = customer_email
if customer_phone:
customer['phone'] = customer_phone
if not customer:
# Fallback email, чтобы API не отказал
customer['email'] = 'user@vpn.bot'
payload['customer'] = customer
if prefer_methods:
payload['prefer_methods'] = prefer_methods
if success_url:
payload['success_url'] = success_url
if fail_url:
payload['fail_url'] = fail_url
if merchant_extra:
payload['merchant_extra'] = merchant_extra[:255]
json_body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
logger.info(
'Antilopay API create_payment',
order_id=order_id,
amount_rubles=amount_rubles,
prefer_methods=prefer_methods,
)
try:
session = await self._get_session()
async with session.post(
f'{API_BASE_URL}/payment/create',
data=json_body,
headers=self._build_headers(json_body),
) as response:
data = await response.json(content_type=None)
api_code = data.get('code')
if response.status == 200 and api_code == 0:
logger.info(
'Antilopay API payment created',
order_id=order_id,
payment_id=data.get('payment_id'),
payment_url=data.get('payment_url'),
)
return data
error_msg = data.get('message') or data.get('error') or str(data)
logger.error(
'Antilopay create_payment error',
status_code=response.status,
api_code=api_code,
error_msg=error_msg,
response_data=data,
)
raise AntilopayAPIError(response.status, error_msg, api_code)
except aiohttp.ClientError as e:
logger.exception('Antilopay API connection error', error=e)
raise
async def check_payment(
self,
*,
order_id: str,
) -> dict[str, Any]:
"""
Проверяет статус платежа.
POST /payment/check
"""
payload: dict[str, Any] = {
'project_identificator': self.project_id,
'order_id': order_id,
}
json_body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
logger.info('Antilopay check_payment', order_id=order_id)
try:
session = await self._get_session()
async with session.post(
f'{API_BASE_URL}/payment/check',
data=json_body,
headers=self._build_headers(json_body),
) as response:
data = await response.json(content_type=None)
if response.status == 200:
return data
error_msg = data.get('message') or data.get('error') or str(data)
logger.error(
'Antilopay check_payment error',
status_code=response.status,
error_msg=error_msg,
)
raise AntilopayAPIError(response.status, error_msg)
except aiohttp.ClientError as e:
logger.exception('Antilopay API connection error', error=e)
raise
def verify_callback_signature(self, raw_body: bytes, received_signature: str) -> bool:
"""Верификация подписи callback Antilopay через SHA256WithRSA.
Подпись приходит в заголовке X-Apay-Callback.
Проверяется ПУБЛИЧНЫМ ключом.
"""
try:
if not received_signature:
logger.warning('Antilopay callback: отсутствует X-Apay-Callback')
return False
rsa_key = RSA.import_key(base64.b64decode(self.public_key))
h = SHA256.new(raw_body)
signature_bytes = base64.b64decode(received_signature)
pkcs1_15.new(rsa_key).verify(h, signature_bytes)
return True
except (ValueError, TypeError) as e:
logger.warning('Antilopay callback: invalid signature', error=str(e))
return False
except Exception as e:
logger.error('Antilopay callback verify error', error=e)
return False
# Singleton instance
antilopay_service = AntilopayService()
+217
View File
@@ -0,0 +1,217 @@
"""Сервис для работы с API AuraPay (aurapay.tech)."""
import hashlib
import hmac
from typing import Any
import aiohttp
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
API_BASE_URL = 'https://app.aurapay.tech'
class AuraPayAPIError(Exception):
"""Ошибка API AuraPay."""
def __init__(self, status_code: int, message: str):
self.status_code = status_code
self.message = message
super().__init__(f'AuraPay API error ({status_code}): {message}')
class AuraPayService:
"""Сервис для работы с API AuraPay."""
def __init__(self):
self._session: aiohttp.ClientSession | None = None
@property
def api_key(self) -> str:
return settings.AURAPAY_API_KEY or ''
@property
def shop_id(self) -> str:
return settings.AURAPAY_SHOP_ID or ''
@property
def secret_key(self) -> str:
return settings.AURAPAY_SECRET_KEY or ''
async def _get_session(self) -> aiohttp.ClientSession:
"""Возвращает переиспользуемую HTTP-сессию."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
)
return self._session
async def close(self) -> None:
"""Закрывает HTTP-сессию."""
if self._session and not self._session.closed:
await self._session.close()
self._session = None
def _build_headers(self) -> dict[str, str]:
"""Строит заголовки запроса с X-ApiKey и X-ShopId."""
return {
'Content-Type': 'application/json',
'X-ApiKey': self.api_key,
'X-ShopId': self.shop_id,
}
async def create_invoice(
self,
*,
amount: float,
order_id: str,
comment: str = '',
service: str | None = None,
success_url: str | None = None,
fail_url: str | None = None,
callback_url: str | None = None,
custom_fields: str | None = None,
lifetime: int | None = None,
) -> dict[str, Any]:
"""
Создает инвойс через API AuraPay.
POST /invoice/create
"""
payload: dict[str, Any] = {
'amount': amount,
'order_id': order_id,
}
if comment:
payload['comment'] = comment
if service:
payload['service'] = service
if success_url:
payload['success_url'] = success_url
if fail_url:
payload['fail_url'] = fail_url
if callback_url:
payload['callback_url'] = callback_url
if custom_fields:
payload['custom_fields'] = custom_fields
if lifetime is not None:
payload['lifetime'] = lifetime
logger.info(
'AuraPay API create_invoice',
order_id=order_id,
amount=amount,
service=service,
)
try:
session = await self._get_session()
async with session.post(
f'{API_BASE_URL}/invoice/create',
json=payload,
headers=self._build_headers(),
) as response:
data = await response.json(content_type=None)
if response.status == 200:
logger.info(
'AuraPay API invoice created',
order_id=order_id,
invoice_id=data.get('id'),
status=data.get('status'),
)
return data
error_msg = data.get('message') or data.get('error') or str(data)
logger.error(
'AuraPay create_invoice error',
status_code=response.status,
error_msg=error_msg,
response_data=data,
)
raise AuraPayAPIError(response.status, error_msg)
except aiohttp.ClientError as e:
logger.exception('AuraPay API connection error', error=e)
raise
async def get_invoice_status(
self,
*,
order_id: str | None = None,
invoice_id: str | None = None,
) -> dict[str, Any]:
"""
Получает статус инвойса.
POST /invoice/status
"""
if not order_id and not invoice_id:
raise ValueError('Either order_id or invoice_id must be provided')
payload: dict[str, str] = {}
if order_id:
payload['order_id'] = order_id
if invoice_id:
payload['id'] = invoice_id
logger.info('AuraPay get_invoice_status', order_id=order_id, invoice_id=invoice_id)
try:
session = await self._get_session()
async with session.post(
f'{API_BASE_URL}/invoice/status',
json=payload,
headers=self._build_headers(),
) as response:
data = await response.json(content_type=None)
if response.status == 200:
return data
error_msg = data.get('message') or data.get('error') or str(data)
logger.error(
'AuraPay get_invoice_status error',
status_code=response.status,
error_msg=error_msg,
)
raise AuraPayAPIError(response.status, error_msg)
except aiohttp.ClientError as e:
logger.exception('AuraPay API connection error', error=e)
raise
def verify_webhook_signature(self, payload: dict[str, Any], received_signature: str) -> bool:
"""Верификация подписи webhook AuraPay через HMAC-SHA256.
Algorithm: sort JSON keys alphabetically, concatenate all VALUES
(converted to str) into one string, HMAC-SHA256 with secret key #2.
Header: X-SIGNATURE
"""
try:
if not received_signature:
logger.warning('AuraPay webhook: отсутствует X-SIGNATURE')
return False
# Сортируем ключи по алфавиту и конкатенируем значения
# None → '' (PHP implode() converts null to empty string, not "None")
sorted_keys = sorted(payload.keys())
concatenated_values = ''.join(str(payload[key]) if payload[key] is not None else '' for key in sorted_keys)
expected = hmac.new(
self.secret_key.encode('utf-8'),
concatenated_values.encode('utf-8'),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, received_signature)
except Exception as e:
logger.error('AuraPay webhook verify error', error=e)
return False
# Singleton instance
aurapay_service = AuraPayService()
+56
View File
@@ -31,6 +31,8 @@ from app.database.models import (
AdminRole,
AdvertisingCampaign,
AdvertisingCampaignRegistration,
AppleTransaction,
AuraPayPayment,
BroadcastHistory,
ButtonClickLog,
CabinetRefreshToken,
@@ -40,18 +42,27 @@ from app.database.models import (
ContestTemplate,
CryptoBotPayment,
DiscountOffer,
EmailTemplate,
FaqPage,
FaqSetting,
FreekassaPayment,
GuestPurchase,
HeleketPayment,
InfoPage,
KassaAiPayment,
LandingPage,
MainMenuButton,
MenuLayoutHistory,
MonitoringLog,
MulenPayPayment,
NewsArticle,
NewsCategory,
NewsTag,
OverpayPayment,
Pal24Payment,
PartnerApplication,
PaymentMethodConfig,
PayPearPayment,
PinnedMessage,
PlategaPayment,
Poll,
@@ -71,9 +82,13 @@ from app.database.models import (
ReferralContestVirtualParticipant,
ReferralEarning,
RequiredChannel,
RioPayPayment,
RollyPayPayment,
SavedPaymentMethod,
SentNotification,
ServerSquad,
ServiceRule,
SeverPayPayment,
Squad,
Subscription,
SubscriptionConversion,
@@ -102,6 +117,7 @@ from app.database.models import (
WheelPrize,
WheelSpin,
WithdrawalRequest,
YandexClientIdMap,
YooKassaPayment,
payment_method_promo_groups,
server_squad_promo_groups,
@@ -183,6 +199,14 @@ class BackupService:
CloudPaymentsPayment,
FreekassaPayment,
KassaAiPayment,
RioPayPayment,
SeverPayPayment,
PayPearPayment,
RollyPayPayment,
OverpayPayment,
AuraPayPayment,
AppleTransaction,
SavedPaymentMethod,
# --- Settings/content ---
PaymentMethodConfig,
PrivacyPolicy,
@@ -192,6 +216,17 @@ class BackupService:
PinnedMessage,
MainMenuButton,
MenuLayoutHistory,
EmailTemplate,
InfoPage,
# --- News (FK: none / self-contained) ---
NewsCategory,
NewsTag,
NewsArticle,
# --- Landing / Guest purchases (FK: users, tariffs, landings) ---
LandingPage,
GuestPurchase,
# --- Yandex analytics (FK: users) ---
YandexClientIdMap,
# --- User data (FK: users, promo_groups, subscriptions) ---
UserPromoGroup,
TrafficPurchase,
@@ -1476,6 +1511,16 @@ class BackupService:
'cloudpayments_payments',
'freekassa_payments',
'kassa_ai_payments',
'riopay_payments',
'severpay_payments',
'paypear_payments',
'rollypay_payments',
'overpay_payments',
'aurapay_payments',
'etoplatezhi_payments',
'antilopay_payments',
'apple_transactions',
'saved_payment_methods',
# --- Content/config ---
'pinned_messages',
'main_menu_buttons',
@@ -1485,6 +1530,17 @@ class BackupService:
'privacy_policies',
'public_offers',
'payment_method_configs',
'email_templates',
'info_pages',
# --- News ---
'news_articles',
'news_categories',
'news_tags',
# --- Landing / Guest purchases ---
'guest_purchases',
'landing_pages',
# --- Yandex analytics ---
'yandex_client_id_map',
# --- Support ---
'support_audit_logs',
'ticket_messages',
+12 -20
View File
@@ -159,17 +159,13 @@ class AdvertisingCampaignService:
device_limit = campaign.subscription_device_limit
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
squads = list(campaign.subscription_squads or [])
try:
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
if not squads:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(db)
if trial_uuid:
squads = [trial_uuid]
except Exception as error:
logger.error('Не удалось подобрать сквад для кампании', campaign_id=campaign.id, error=error)
squads = await get_effective_tariff_squad_uuids(db, campaign.subscription_squads)
except Exception as error:
logger.error('Не удалось подобрать сквады для кампании', campaign_id=campaign.id, error=error)
squads = list(campaign.subscription_squads or [])
if existing_subscription:
# Multi-tariff: extend the best existing subscription
@@ -305,17 +301,13 @@ class AdvertisingCampaignService:
traffic_limit = tariff.traffic_limit_gb
device_limit = tariff.device_limit
squads = list(tariff.allowed_squads or [])
try:
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
if not squads:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(db)
if trial_uuid:
squads = [trial_uuid]
except Exception as error:
logger.error('Не удалось подобрать сквад для тарифа кампании', campaign_id=campaign.id, error=error)
squads = await get_effective_tariff_squad_uuids(db, tariff.allowed_squads)
except Exception as error:
logger.error('Не удалось подобрать сквады для тарифа кампании', campaign_id=campaign.id, error=error)
squads = list(tariff.allowed_squads or [])
if existing_subscription:
# Multi-tariff: extend the existing subscription for this tariff
+15 -2
View File
@@ -146,9 +146,22 @@ class DailySubscriptionService:
# Недостаточно средств - приостанавливаем подписку
await suspend_daily_subscription_insufficient_balance(db, subscription)
# Уведомляем пользователя
# Уведомляем пользователя (rate-limit: 1 раз в 6 часов)
if self._bot:
await self._notify_insufficient_balance(user, subscription, daily_price)
from app.utils.cache import cache
cache_key = f'daily_insuf_notify:{subscription.id}'
try:
already_notified = await cache.get(cache_key)
except Exception:
already_notified = None
if not already_notified:
await self._notify_insufficient_balance(user, subscription, daily_price)
try:
await cache.set(cache_key, '1', expire=21600) # 6 hours
except Exception:
pass
logger.info(
'Подписка приостановлена: недостаточно средств (баланс: требуется: )',
+266
View File
@@ -0,0 +1,266 @@
"""Сервис для работы с API Donut (Donut P2P, gw.donut.business)."""
import hashlib
import hmac
import json
from typing import Any
import aiohttp
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
class DonutAPIError(Exception):
"""Ошибка API Donut."""
def __init__(self, status_code: int, message: str, code: str | None = None) -> None:
self.status_code = status_code
self.message = message
self.api_code = code
super().__init__(f'Donut API error ({status_code}): {message}')
class DonutService:
"""Клиент для Donut P2P (gw.donut.business)."""
def __init__(self) -> None:
self._session: aiohttp.ClientSession | None = None
@property
def base_url(self) -> str:
return (settings.DONUT_BASE_URL or 'https://gw.donut.business').rstrip('/')
@property
def token(self) -> str:
return settings.DONUT_TOKEN or ''
@property
def secret(self) -> str:
return settings.DONUT_SECRET or ''
@property
def method_id(self) -> str | None:
value = (settings.DONUT_METHOD_ID or '').strip()
return value or None
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
)
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
self._session = None
@staticmethod
def _build_signature_string(parts: list[tuple[str, Any]]) -> str:
"""Собирает каноническую строку для подписи: имя=значение (без разделителя)."""
chunks: list[str] = []
for key, value in parts:
if value is None:
continue
if isinstance(value, bool):
chunks.append(f'{key}={"true" if value else "false"}')
else:
value_str = str(value)
if value_str == '':
continue
chunks.append(f'{key}={value_str}')
return ''.join(chunks)
def _hmac_hex(self, message: str) -> str:
"""HMAC-SHA256 в hex."""
return hmac.new(
self.secret.encode('utf-8'),
msg=message.encode('utf-8'),
digestmod=hashlib.sha256,
).hexdigest()
def _sign_payin(self, payload: dict[str, Any]) -> str:
amount = payload['amount']
customer = payload['customer']
parts: list[tuple[str, Any]] = [
('token', payload['token']),
('order_id', payload['order_id']),
('amount.value', amount['value']),
('amount.currency', amount['currency']),
('customer.id', customer['id']),
('redirect', payload['redirect']),
]
return self._hmac_hex(self._build_signature_string(parts))
def _sign_status(self, payload: dict[str, Any]) -> str:
parts: list[tuple[str, Any]] = [
('token', payload['token']),
('transaction_id', payload['transaction_id']),
]
return self._hmac_hex(self._build_signature_string(parts))
def _sign_balance(self, payload: dict[str, Any]) -> str:
parts: list[tuple[str, Any]] = [
('token', payload['token']),
]
return self._hmac_hex(self._build_signature_string(parts))
@staticmethod
def _format_amount(amount_rubles: float) -> str:
"""Сумма строго '0.00' с точкой (требование Donut P2P)."""
return f'{float(amount_rubles):.2f}'
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
url = f'{self.base_url}/{path.lstrip("/")}'
body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
try:
session = await self._get_session()
async with session.post(
url,
data=body,
headers={'Content-Type': 'application/json'},
) as response:
data = await response.json(content_type=None)
return data if isinstance(data, dict) else {'_raw': data}
except aiohttp.ClientError as error:
logger.exception('Donut API connection error', url=url, error=error)
raise
async def create_payment(
self,
*,
amount_rubles: float,
order_id: str,
customer_id: str,
method_description: str,
customer_email: str | None = None,
customer_phone: str | None = None,
callback_url: str | None = None,
return_url: str | None = None,
receipt: str | None = None,
redirect: bool = True,
) -> dict[str, Any]:
"""Создаёт платёж (PayIn) через Donut P2P.
POST /p2p_payin
"""
payload: dict[str, Any] = {
'token': self.token,
'order_id': order_id,
'amount': {
'value': self._format_amount(amount_rubles),
'currency': (settings.DONUT_CURRENCY or 'RUB').upper(),
},
'customer': {'id': str(customer_id)},
'redirect': 'true' if redirect else 'false',
'description': method_description,
}
if customer_email:
payload['customer']['email'] = customer_email
if customer_phone:
payload['customer']['phone'] = customer_phone
if self.method_id:
payload['method_id'] = self.method_id
if callback_url:
payload['callback_url'] = callback_url
if return_url:
payload['return_url'] = return_url
if receipt:
payload['receipt'] = receipt[:255]
payload['signature'] = self._sign_payin(payload)
logger.info(
'Donut API create_payment',
order_id=order_id,
amount_rubles=amount_rubles,
description=method_description,
)
data = await self._post('/p2p_payin', payload)
status_obj = (data.get('status') or {}) if isinstance(data, dict) else {}
status_type = status_obj.get('type')
if status_type in ('processing', 'success', 'created'):
logger.info(
'Donut API payment created',
order_id=order_id,
transaction_id=data.get('transaction_id'),
status_type=status_type,
)
return data
error_code = status_obj.get('error_code') or '0'
error_msg = status_obj.get('error_description') or status_obj.get('message') or 'Unknown error'
logger.error(
'Donut create_payment error',
error_code=error_code,
error_msg=error_msg,
response_data=data,
)
raise DonutAPIError(200, error_msg, error_code)
async def check_payment(self, *, transaction_id: str) -> dict[str, Any]:
"""Получает статус платежа.
POST /p2p_status
"""
payload: dict[str, Any] = {
'token': self.token,
'transaction_id': str(transaction_id),
}
payload['signature'] = self._sign_status(payload)
logger.info('Donut check_payment', transaction_id=transaction_id)
return await self._post('/p2p_status', payload)
async def get_balance(self) -> dict[str, Any]:
"""Получает баланс продавца.
POST /p2p_balance
"""
payload: dict[str, Any] = {'token': self.token}
payload['signature'] = self._sign_balance(payload)
return await self._post('/p2p_balance', payload)
def verify_callback_signature(self, payload: dict[str, Any]) -> bool:
"""Верификация подписи callback (HMAC-SHA256, hex)."""
try:
received = (payload.get('signature') or '').strip()
if not received:
logger.warning('Donut callback: отсутствует signature')
return False
amount = payload.get('amount') or {}
status_obj = payload.get('status') or {}
parts: list[tuple[str, Any]] = [
('token', payload.get('token')),
('transaction_id', payload.get('transaction_id')),
('order_id', payload.get('order_id')),
('amount.value', amount.get('value')),
('amount.currency', amount.get('currency')),
('recalculated', payload.get('recalculated')),
('status.type', status_obj.get('type')),
]
expected = self._hmac_hex(self._build_signature_string(parts))
if not hmac.compare_digest(expected.lower(), received.lower()):
logger.warning(
'Donut callback: invalid signature',
expected_prefix=expected[:8],
received_prefix=received[:8],
)
return False
return True
except Exception as error:
logger.error('Donut callback verify error', error=error)
return False
# Singleton instance
donut_service = DonutService()
+202
View File
@@ -0,0 +1,202 @@
"""Сервис для работы с Etoplatezhi (paymentpage.etoplatezhi.ru)."""
import base64
import hashlib
import hmac
from typing import Any
from urllib.parse import urlencode
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
PAYMENT_PAGE_BASE_URL = 'https://paymentpage.etoplatezhi.ru/payment'
class EtoplatezhiService:
"""Сервис для построения URL платежей и верификации callback-ов Etoplatezhi."""
@property
def project_id(self) -> int:
return settings.ETOPLATEZHI_PROJECT_ID or 0
@property
def secret_key(self) -> str:
return settings.ETOPLATEZHI_SECRET_KEY or ''
def _flatten_params(
self,
params: dict[str, Any],
prefix: str = '',
ignore: set[str] | None = None,
) -> list[str]:
"""Рекурсивно «сплющивает» вложенные словари в список 'key:value' строк.
Keys разделяются двоеточием. ``frame_mode`` и ``signature`` игнорируются.
Booleans приводятся к '1'/'0'.
Empty arrays (lists) are excluded entirely per Etoplatezhi spec.
"""
if ignore is None:
ignore = {'frame_mode', 'signature'}
entries: list[str] = []
for key, value in params.items():
full_key = f'{prefix}:{key}' if prefix else key
if full_key in ignore or key in ignore:
continue
if isinstance(value, dict):
entries.extend(self._flatten_params(value, prefix=full_key, ignore=ignore))
elif isinstance(value, list):
# Empty arrays are excluded entirely per spec
if not value:
continue
# Non-empty arrays: flatten each element with index as key
for idx, item in enumerate(value):
item_key = f'{full_key}:{idx}'
if isinstance(item, dict):
entries.extend(self._flatten_params(item, prefix=item_key, ignore=ignore))
elif isinstance(item, bool):
entries.append(f'{item_key}:{"1" if item else "0"}')
elif item is not None:
entries.append(f'{item_key}:{item}')
elif isinstance(value, bool):
entries.append(f'{full_key}:{"1" if value else "0"}')
elif value is not None:
entries.append(f'{full_key}:{value}')
return entries
def _sign(self, params: dict[str, Any]) -> str:
"""HMAC-SHA512 + base64 подпись параметров.
Algorithm:
1. Flatten nested dicts with ':' separator.
2. Each leaf "key:value".
3. Sort alphabetically by full key string.
4. Join with ';'.
5. HMAC-SHA512 with secret_key.
6. base64-encode the raw digest.
"""
entries = self._flatten_params(params)
entries.sort()
message = ';'.join(entries)
digest = hmac.new(
self.secret_key.encode('utf-8'),
message.encode('utf-8'),
hashlib.sha512,
).digest()
return base64.b64encode(digest).decode('utf-8')
def build_payment_url(
self,
*,
project_id: int,
payment_id: str,
payment_amount: int,
payment_currency: str = 'RUB',
customer_id: str,
description: str | None = None,
callback_url: str | None = None,
success_url: str | None = None,
fail_url: str | None = None,
force_payment_method: str | None = None,
customer_email: str | None = None,
language_code: str | None = None,
) -> str:
"""Строит URL для редиректа на платёжную страницу Etoplatezhi.
Args:
project_id: ID проекта в Etoplatezhi.
payment_id: Наш internal order_id.
payment_amount: Сумма в минорных единицах (копейках).
payment_currency: ISO 4217 код валюты.
customer_id: Telegram ID или guest-идентификатор покупателя.
description: Описание платежа.
callback_url: URL для callback (POST JSON).
success_url: URL редиректа при успехе.
fail_url: URL редиректа при ошибке.
force_payment_method: 'sbp' или 'card' для принудительного выбора.
customer_email: Email покупателя.
language_code: Язык интерфейса ('ru', 'en').
Returns:
Полный URL с параметрами и подписью.
"""
params: dict[str, Any] = {
'project_id': project_id,
'payment_id': payment_id,
'payment_amount': payment_amount,
'payment_currency': payment_currency,
'customer_id': customer_id,
}
if description:
params['payment_description'] = description
if callback_url:
params['merchant_callback_url'] = callback_url
if success_url:
params['redirect_success_url'] = success_url
if fail_url:
params['redirect_fail_url'] = fail_url
if force_payment_method:
params['force_payment_method'] = force_payment_method
if customer_email:
params['customer_email'] = customer_email
if language_code:
params['language_code'] = language_code
params['signature'] = self._sign(params)
logger.info(
'Etoplatezhi: building payment URL',
payment_id=payment_id,
payment_amount=payment_amount,
customer_id=customer_id,
)
return f'{PAYMENT_PAGE_BASE_URL}?{urlencode(params)}'
def verify_callback_signature(self, payload: dict[str, Any]) -> bool:
"""Верифицирует подпись в callback-е Etoplatezhi.
Подпись находится внутри JSON body (поле ``signature``).
Для проверки: удаляем ``signature`` из всех уровней вложенности,
вычисляем подпись по оставшимся данным и сравниваем.
"""
try:
received_signature = payload.get('signature')
if not received_signature:
logger.warning('Etoplatezhi callback: отсутствует signature в payload')
return False
# Deep-copy payload and strip all 'signature' keys recursively
cleaned = self._strip_signature_keys(payload)
expected = self._sign(cleaned)
return hmac.compare_digest(expected, str(received_signature))
except Exception as e:
logger.error('Etoplatezhi callback verify error', error=e)
return False
def _strip_signature_keys(self, data: dict[str, Any]) -> dict[str, Any]:
"""Рекурсивно удаляет ключ ``signature`` из словаря и вложенных словарей."""
result: dict[str, Any] = {}
for key, value in data.items():
if key == 'signature':
continue
if isinstance(value, dict):
result[key] = self._strip_signature_keys(value)
else:
result[key] = value
return result
# Singleton instance
etoplatezhi_service = EtoplatezhiService()
-146
View File
@@ -1,146 +0,0 @@
"""Утилиты для синхронизации токена внешней админки."""
from __future__ import annotations
import structlog
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import SystemSetting
from app.services.system_settings_service import (
ReadOnlySettingError,
bot_configuration_service,
)
logger = structlog.get_logger(__name__)
async def ensure_external_admin_token(
bot_username: str | None,
bot_id: int | None,
) -> str | None:
"""Генерирует и сохраняет токен внешней админки, если требуется."""
username_raw = (bot_username or '').strip()
if not username_raw:
logger.warning(
'⚠️ Не удалось обеспечить токен внешней админки: username бота отсутствует',
)
return None
normalized_username = username_raw.lstrip('@').lower()
if not normalized_username:
logger.warning(
'⚠️ Не удалось обеспечить токен внешней админки: username пустой после нормализации',
)
return None
try:
token = settings.build_external_admin_token(normalized_username)
except Exception as error: # pragma: no cover - защитный блок
logger.error('❌ Ошибка генерации токена внешней админки', error=error)
return None
try:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(SystemSetting.key, SystemSetting.value).where(
SystemSetting.key.in_(['EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'])
)
)
rows = dict(result.all())
existing_token = rows.get('EXTERNAL_ADMIN_TOKEN')
existing_bot_id_raw = rows.get('EXTERNAL_ADMIN_TOKEN_BOT_ID')
existing_bot_id: int | None = None
if existing_bot_id_raw is not None:
try:
existing_bot_id = int(existing_bot_id_raw)
except (TypeError, ValueError): # pragma: no cover - защита от мусорных значений
logger.warning(
'⚠️ Не удалось разобрать сохраненный идентификатор бота внешней админки',
existing_bot_id_raw=existing_bot_id_raw,
)
if existing_token == token and existing_bot_id == bot_id:
if settings.get_external_admin_token() != token:
settings.EXTERNAL_ADMIN_TOKEN = token
if existing_bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID:
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
return token
if existing_bot_id is not None and bot_id is not None and existing_bot_id != bot_id:
logger.error(
'❌ Обнаружено несовпадение ID бота для токена внешней админки: сохранен , текущий',
existing_bot_id=existing_bot_id,
bot_id=bot_id,
)
try:
await bot_configuration_service.reset_value(
session,
'EXTERNAL_ADMIN_TOKEN',
force=True,
)
await bot_configuration_service.reset_value(
session,
'EXTERNAL_ADMIN_TOKEN_BOT_ID',
force=True,
)
await session.commit()
logger.warning(
'⚠️ Токен внешней админки очищен из-за несовпадения идентификаторов бота',
)
except Exception as cleanup_error: # pragma: no cover - защитный блок
await session.rollback()
logger.error(
'❌ Не удалось очистить токен внешней админки после обнаружения подмены',
cleanup_error=cleanup_error,
)
finally:
settings.EXTERNAL_ADMIN_TOKEN = None
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = None
return None
updates: list[tuple[str, object]] = []
if existing_token != token:
updates.append(('EXTERNAL_ADMIN_TOKEN', token))
if bot_id is not None and existing_bot_id != bot_id:
updates.append(('EXTERNAL_ADMIN_TOKEN_BOT_ID', bot_id))
if not updates:
# Токен совпал, но могли отсутствовать значения в настройках приложения
if settings.get_external_admin_token() != (existing_token or token):
settings.EXTERNAL_ADMIN_TOKEN = existing_token or token
if existing_bot_id is not None and (existing_bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID):
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
elif bot_id is not None and bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID and existing_bot_id is None:
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = bot_id
return existing_token or token
try:
for key, value in updates:
await bot_configuration_service.set_value(
session,
key,
value,
force=True,
)
await session.commit()
logger.info('✅ Токен внешней админки синхронизирован для @', normalized_username=normalized_username)
except ReadOnlySettingError: # pragma: no cover - force=True предотвращает исключение
await session.rollback()
logger.warning(
'⚠️ Не удалось сохранить токен внешней админки из-за ограничения доступа',
)
return None
return token
except SQLAlchemyError as error:
logger.error('❌ Ошибка сохранения токена внешней админки', error=error)
return None

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