Compare commits

..

69 Commits

Author SHA1 Message Date
Fringg 29d3e8984b fix(smtp): use implicit TLS (SMTPS) for port 465 registration emails
The cabinet email service called smtplib.SMTP() and conditionally ran
starttls() regardless of port, which is wrong for port 465. Port 465
is SMTPS (implicit TLS, RFC 8314): the TLS handshake must happen on
connect, before any EHLO. With the old code, switching to 465
produced a corrupted handshake — sometimes one garbled message went
through, then the server dropped the source.

Now SMTP_SSL is used when SMTP_USE_SSL is true OR when SMTP_PORT is
465 (auto-detect). The new SMTP_USE_SSL setting defaults to false
to keep existing 587/25 deployments unchanged.
2026-05-10 10:02:46 +03:00
Fringg b5a066628f fix(cabinet): return original_price_per_device_kopeks for device addon
Endpoint /cabinet/subscription/devices/price did not include
original_price_per_device_kopeks when a promo group discount applied,
causing the cabinet UI to render a strikethrough 'Бесплатно' next to
the per-device price line.
2026-05-10 09:59:52 +03:00
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
115 changed files with 12532 additions and 458 deletions
+4 -7
View File
@@ -72,8 +72,11 @@ SMTP_PASSWORD=
# Email отправителя (если не указан, используется SMTP_USER)
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=VPN Service
# Использовать TLS шифрование
# Использовать STARTTLS (порт 587 / 25). Не путать с SMTP_USE_SSL.
SMTP_USE_TLS=true
# Использовать implicit TLS (SMTPS). Автоматически включается при SMTP_PORT=465.
# Для портов 25/587 оставить false.
SMTP_USE_SSL=false
# Уведомления администраторов
ADMIN_NOTIFICATIONS_ENABLED=true
@@ -1029,10 +1032,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.52.0"
".": "3.54.0"
}
+85
View File
@@ -1,5 +1,90 @@
# 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)
+3 -3
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.52.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
+34 -2
View File
@@ -55,7 +55,7 @@ Bedolaga — полнофункциональная платформа для п
### 💳 Платежи
- 🏦 **18 платёжных провайдеров** одновременно
- 🏦 **24+ платёжных провайдера** одновременно
- 💰 Единый баланс: пополнение любым способом → покупка с баланса
- ⚡ Автопокупка подписки после пополнения
- 💾 Рекуррентные платежи (сохранённые карты)
@@ -127,6 +127,12 @@ Bedolaga — полнофункциональная платформа для п
| 🤝 | **[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>
@@ -210,6 +216,32 @@ Bedolaga — официальный партнёр платёжного шлюз
📩 Менеджер: [@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>
@@ -275,7 +307,7 @@ docker compose up -d
| | Раздел | Описание |
|:---:|:---|:---|
| 🚀 | [Быстрый старт](https://docs.bedolagam.ru/getting-started/quickstart) | Развёртывание за 5 минут |
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 18 провайдеров, 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
+12
View File
@@ -69,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)
@@ -81,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
+11 -1
View File
@@ -502,6 +502,16 @@ async def _do_delete_subscription(
tariff_name = sub.tariff.name if sub.tariff else f'#{sub.id}'
# Protect active paid subscriptions from accidental deletion
if sub.is_active and not sub.is_trial and not params.force_delete_active_paid:
return BulkUserResult(
user_id=user.id,
success=False,
message=f'Skipped: {tariff_name} is active and paid (enable force_delete_active_paid to override)',
username=user.username,
subscriptions=_build_subscription_info(getattr(user, 'subscriptions', None) or []),
)
if dry_run:
return BulkUserResult(
user_id=user.id,
@@ -890,7 +900,7 @@ async def _execute_for_subscription(
async def bulk_execute(
request: BulkExecuteRequest,
stream: bool = Query(default=False, description='Stream progress via SSE'),
admin: User = Depends(require_permission('users:edit')),
admin: User = Depends(require_permission('bulk_actions:execute')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Execute a bulk action on multiple users or subscriptions.
+7 -7
View File
@@ -34,7 +34,7 @@ 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('settings:read')),
admin: User = Depends(require_permission('info_pages:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> list[InfoPageListItem]:
"""Get all info pages (admin view, includes inactive)."""
@@ -54,7 +54,7 @@ async def list_all_info_pages(
@router.get('/{page_id}', response_model=InfoPageResponse)
async def get_info_page_detail(
page_id: int,
admin: User = Depends(require_permission('settings:read')),
admin: User = Depends(require_permission('info_pages:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Get a single info page by ID (admin view)."""
@@ -70,7 +70,7 @@ async def get_info_page_detail(
@router.post('', response_model=InfoPageResponse, status_code=status.HTTP_201_CREATED)
async def create_page(
request: InfoPageCreateRequest,
admin: User = Depends(require_permission('settings:edit')),
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Create a new info page."""
@@ -108,7 +108,7 @@ async def create_page(
async def update_page(
page_id: int,
request: InfoPageUpdateRequest,
admin: User = Depends(require_permission('settings:edit')),
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Update an existing info page."""
@@ -150,7 +150,7 @@ async def update_page(
@router.delete('/{page_id}', status_code=status.HTTP_204_NO_CONTENT)
async def remove_page(
page_id: int,
admin: User = Depends(require_permission('settings:edit')),
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete an info page."""
@@ -174,7 +174,7 @@ async def remove_page(
@router.post('/reorder', status_code=status.HTTP_204_NO_CONTENT)
async def reorder_pages(
request: ReorderRequest,
admin: User = Depends(require_permission('settings:edit')),
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Bulk update sort_order for info pages."""
@@ -191,7 +191,7 @@ async def reorder_pages(
@router.post('/{page_id}/toggle-active', response_model=InfoPageResponse)
async def toggle_active(
page_id: int,
admin: User = Depends(require_permission('settings:edit')),
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Toggle the active status of an info page."""
+88 -9
View File
@@ -1,5 +1,6 @@
"""Admin routes for managing users in cabinet."""
import math
from datetime import UTC, datetime, timedelta
import structlog
@@ -157,6 +158,7 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt
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,
@@ -391,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
@@ -895,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,
@@ -1743,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)
@@ -1754,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 ===
@@ -3159,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,
@@ -3362,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)
+102 -60
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)
@@ -837,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)
+142
View File
@@ -914,6 +914,99 @@ async def create_topup(
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(
@@ -1065,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 '', 'Неизвестно'
@@ -1206,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,
)
@@ -1231,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)
+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
@@ -380,7 +381,7 @@ 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
# Устройства в пределах тарифного лимита — бесплатные
@@ -658,7 +659,7 @@ 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
# Устройства в пределах тарифного лимита — бесплатные
@@ -772,7 +773,7 @@ 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
# Устройства в пределах тарифного лимита — бесплатные
@@ -827,6 +828,9 @@ async def get_device_price(
response['discount_percent'] = devices_discount_percent
response['discount_kopeks'] = discount_value
response['base_total_price_kopeks'] = base_total_price
response['original_price_per_device_kopeks'] = (
base_total_price // devices if devices > 0 else 0
)
return response
@@ -895,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,
@@ -905,7 +911,6 @@ async def purchase_tariff(
sync_squads=True,
)
else:
# New subscription — create new Remnawave user
await service.create_remnawave_user(
db,
subscription,
@@ -919,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)
@@ -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,
}
@@ -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,
+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
+1
View File
@@ -29,6 +29,7 @@ class BulkActionParams(BaseModel):
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):
+1
View File
@@ -89,6 +89,7 @@ class SubscriptionListItem(BaseModel):
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
+13 -5
View File
@@ -45,18 +45,26 @@ class EmailService:
def use_tls(self) -> bool:
return settings.SMTP_USE_TLS
@property
def use_ssl(self) -> bool:
# Port 465 always implies implicit TLS (SMTPS, RFC 8314).
return settings.SMTP_USE_SSL or self.port == 465
def is_configured(self) -> bool:
"""Check if SMTP is properly configured."""
return settings.is_smtp_configured()
def _get_smtp_connection(self) -> smtplib.SMTP:
"""Create and return SMTP connection."""
smtp = smtplib.SMTP(self.host, self.port, timeout=30)
smtp.ehlo()
if self.use_tls:
smtp.starttls()
if self.use_ssl:
smtp: smtplib.SMTP = smtplib.SMTP_SSL(self.host, self.port, timeout=30)
smtp.ehlo()
else:
smtp = smtplib.SMTP(self.host, self.port, timeout=30)
smtp.ehlo()
if self.use_tls:
smtp.starttls()
smtp.ehlo()
# Only attempt login if credentials are provided AND server supports AUTH
if self.user and self.password:
+360 -36
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 # Задержка между отправкой чеков (секунды)
@@ -276,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
@@ -606,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
@@ -658,6 +685,108 @@ class Settings(BaseSettings):
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 (красный), '' (по умолчанию для каждой секции)
@@ -844,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
@@ -888,6 +1014,8 @@ class Settings(BaseSettings):
SMTP_FROM_EMAIL: str | None = None
SMTP_FROM_NAME: str = 'VPN Service'
SMTP_USE_TLS: bool = True
# Implicit TLS (SMTPS) — required for port 465. Auto-enabled when SMTP_PORT == 465.
SMTP_USE_SSL: bool = False
# Ban System Integration (BedolagaBan monitoring)
BAN_SYSTEM_ENABLED: bool = False
@@ -1653,37 +1781,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'
@@ -1772,6 +1869,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'
@@ -2051,6 +2152,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
@@ -2101,6 +2230,201 @@ class Settings(BaseSettings):
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
+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
+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
+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
+2 -1
View File
@@ -1,3 +1,4 @@
import math
import secrets
from collections.abc import Iterable
from datetime import UTC, datetime, timedelta
@@ -1296,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
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,
]
+354
View File
@@ -162,10 +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'
@@ -329,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'
@@ -1130,6 +1171,318 @@ class AuraPayPayment(Base):
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'
@@ -1646,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)
+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')]]
),
-1
View File
@@ -158,7 +158,6 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
'LOG',
'MODERATION',
'DEBUG',
'EXTERNAL_ADMIN',
),
},
}
+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=[
+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')
+63 -8
View File
@@ -39,6 +39,7 @@ async def _create_aurapay_payment_and_respond(
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
payment_method_type: str | None = None,
):
"""
Common logic for creating AuraPay payment and sending response.
@@ -61,6 +62,7 @@ async def _create_aurapay_payment_and_respond(
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
payment_method_type=payment_method_type,
)
if not result:
@@ -179,6 +181,11 @@ async def process_aurapay_payment_amount(
)
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(
@@ -187,19 +194,30 @@ async def process_aurapay_payment_amount(
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
payment_method_type=payment_method_type,
)
@error_handler
async def start_aurapay_topup(
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,
db: AsyncSession,
state: FSMContext,
payment_method: str,
):
"""
Start AuraPay top-up process - ask for amount.
"""
"""Common logic for starting AuraPay top-up (generic / SBP / card)."""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
@@ -213,11 +231,18 @@ async def start_aurapay_topup(
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method='aurapay')
await state.update_data(payment_method=payment_method)
min_amount = settings.AURAPAY_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.AURAPAY_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_aurapay_display_name()
# 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=[
@@ -245,3 +270,33 @@ async def start_aurapay_topup(
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')
+80 -2
View File
@@ -170,13 +170,48 @@ async def route_payment_by_method(
await process_overpay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'aurapay':
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
@@ -768,9 +803,52 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_overpay_topup, F.data == 'topup_overpay')
from .aurapay import start_aurapay_topup
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
+4 -2
View File
@@ -118,11 +118,12 @@ 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('/'),
@@ -133,6 +134,7 @@ def register_handlers(dp: Dispatcher):
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,
)
+47 -1
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(
@@ -2416,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
+6 -5
View File
@@ -1,4 +1,5 @@
import html as html_mod
import math
from datetime import UTC, datetime
from aiogram import types
@@ -343,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(
@@ -572,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',
@@ -601,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,
@@ -1253,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(
@@ -1274,7 +1275,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
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(
@@ -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)
+11
View File
@@ -4141,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')
+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',
)
+135 -33
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)
# ==================== Продление по тарифу ====================
@@ -2364,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:
@@ -2377,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
# Запоминаем, был ли триал ДО продления
@@ -2487,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
# ==================== Переключение тарифов ====================
@@ -3031,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:
@@ -3044,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
# Получаем список серверов из тарифа
@@ -3198,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
# ==================== Смена на суточный тариф ====================
@@ -3276,6 +3351,13 @@ async def confirm_daily_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:
@@ -3289,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
# Получаем список серверов из тарифа
@@ -3445,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)
@@ -3478,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
# ==================== Мгновенное переключение тарифов (без выбора периода) ====================
@@ -3976,6 +4063,13 @@ async def confirm_instant_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:
@@ -3991,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
# Получаем список серверов из нового тарифа
@@ -4058,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,
@@ -4232,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,
+278 -11
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
@@ -1843,7 +1850,35 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_aurapay_enabled():
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(
[
@@ -1855,6 +1890,212 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
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(
[
@@ -2169,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:
@@ -2311,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:
@@ -2473,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(
'🔍 Расчет для управления странами: осталось дней до',
@@ -2553,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'
@@ -2608,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'
@@ -2622,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)
@@ -2643,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(
[
@@ -2975,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": "🔗 连接"
}
@@ -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()
+2 -1
View File
@@ -197,8 +197,9 @@ class AuraPayService:
return False
# Сортируем ключи по алфавиту и конкатенируем значения
# None → '' (PHP implode() converts null to empty string, not "None")
sorted_keys = sorted(payload.keys())
concatenated_values = ''.join(str(payload[key]) for key in sorted_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'),
+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',
+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
+268
View File
@@ -0,0 +1,268 @@
"""Сервис для работы с API Jupiter (FPGate P2P v2.1, app.juppiter.tech)."""
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 JupiterAPIError(Exception):
"""Ошибка API Jupiter."""
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'Jupiter API error ({status_code}): {message}')
class JupiterService:
"""Клиент для FPGate P2P v2.1 (Jupiter / app.juppiter.tech)."""
def __init__(self) -> None:
self._session: aiohttp.ClientSession | None = None
@property
def base_url(self) -> str:
return (settings.JUPITER_BASE_URL or 'https://app.juppiter.tech').rstrip('/')
@property
def token(self) -> str:
return settings.JUPITER_TOKEN or ''
@property
def secret(self) -> str:
return settings.JUPITER_SECRET or ''
@property
def method_id(self) -> str | None:
value = (settings.JUPITER_METHOD_ID or '').strip()
return value or None
@property
def method_description(self) -> str:
return (settings.JUPITER_METHOD_DESCRIPTION or 'SBP').strip() or 'SBP'
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:
"""Собирает каноническую строку для подписи: имя=значение... в порядке полей.
По спецификации FPGate P2P v2.1: «Если поле подписываемое, но не обязательное,
то оно входит в подпись, если оно присутствует в запросе и имеет непустое значение».
"""
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"}')
continue
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' с точкой-разделителем (требование P2P v2.1)."""
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('Jupiter API connection error', url=url, error=error)
raise
async def create_payment(
self,
*,
amount_rubles: float,
order_id: str,
customer_id: str,
customer_email: str | None = None,
customer_phone: str | None = None,
customer_name: str | None = None,
callback_url: str | None = None,
receipt: str | None = None,
description: str | None = None,
) -> dict[str, Any]:
"""Создаёт платёж (PayIn) согласно FPGate P2P v2.1.
POST /p2p_payin_v2.1
"""
payload: dict[str, Any] = {
'token': self.token,
'order_id': order_id,
'amount': {
'value': self._format_amount(amount_rubles),
'currency': (settings.JUPITER_CURRENCY or 'RUB').upper(),
},
'customer': {
'id': str(customer_id),
'email': customer_email or settings.JUPITER_FALLBACK_EMAIL or 'user@vpn.bot',
'phone': customer_phone or settings.JUPITER_FALLBACK_PHONE or '0000000000',
'name': customer_name or settings.JUPITER_FALLBACK_NAME or 'User',
},
'redirect': 'false',
'description': (description or self.method_description)[:255],
}
if self.method_id:
payload['method_id'] = self.method_id
if callback_url:
payload['callback_url'] = callback_url
if receipt:
payload['receipt'] = receipt[:255]
payload['signature'] = self._sign_payin(payload)
logger.info('Jupiter API create_payment', order_id=order_id, amount_rubles=amount_rubles)
data = await self._post('/p2p_payin_v2.1', payload)
status = (data.get('status') or {}) if isinstance(data, dict) else {}
status_type = status.get('type')
if status_type in ('processing', 'success'):
logger.info(
'Jupiter API payment created',
order_id=order_id,
transaction_id=data.get('transaction_id'),
status_type=status_type,
)
return data
error_code = status.get('error_code') or '0'
error_msg = status.get('error_description') or 'Unknown error'
logger.error(
'Jupiter create_payment error',
error_code=error_code,
error_msg=error_msg,
response_data=data,
)
raise JupiterAPIError(200, error_msg, error_code)
async def check_payment(self, *, transaction_id: str) -> dict[str, Any]:
"""Получает статус платежа.
POST /p2p_status_v2.1
"""
payload: dict[str, Any] = {
'token': self.token,
'transaction_id': str(transaction_id),
}
payload['signature'] = self._sign_status(payload)
logger.info('Jupiter check_payment', transaction_id=transaction_id)
data = await self._post('/p2p_status_v2.1', payload)
return data
async def get_balance(self) -> dict[str, Any]:
"""Получает баланс продавца.
POST /p2p_balance_v2.1
"""
payload: dict[str, Any] = {'token': self.token}
payload['signature'] = self._sign_balance(payload)
data = await self._post('/p2p_balance_v2.1', payload)
return data
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('Jupiter callback: отсутствует signature')
return False
amount = payload.get('amount') or {}
status = 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.get('type')),
]
expected = self._hmac_hex(self._build_signature_string(parts))
if not hmac.compare_digest(expected.lower(), received.lower()):
logger.warning(
'Jupiter callback: invalid signature',
expected_prefix=expected[:8],
received_prefix=received[:8],
)
return False
return True
except Exception as error:
logger.error('Jupiter callback verify error', error=error)
return False
# Singleton instance
jupiter_service = JupiterService()
+234
View File
@@ -0,0 +1,234 @@
"""Сервис для работы с API Lava Business (gate.lava.ru)."""
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 LavaAPIError(Exception):
"""Ошибка API Lava."""
def __init__(self, status_code: int, message: str, code: str | int | None = None) -> None:
self.status_code = status_code
self.message = message
self.api_code = code
super().__init__(f'Lava API error ({status_code}): {message}')
class LavaService:
"""Клиент для Lava Business API (gate.lava.ru).
Подпись запросов: HMAC-SHA256(json_body, secret_key) hex.
Передаётся в заголовке ``Signature``.
Ключи `secret_key` (запросы) и `secret_key_2` (webhook) выдаются мерчанту в личном кабинете.
Каноническая строка для подписи JSON в том же порядке, в котором отправляется в теле.
"""
def __init__(self) -> None:
self._session: aiohttp.ClientSession | None = None
@property
def base_url(self) -> str:
return (settings.LAVA_BASE_URL or 'https://gate.lava.ru').rstrip('/')
@property
def shop_id(self) -> str:
return settings.LAVA_SHOP_ID or ''
@property
def secret_key(self) -> str:
return settings.LAVA_SECRET_KEY or ''
@property
def webhook_secret(self) -> str:
# secret_key_2 — для проверки подписи webhook'а
return settings.LAVA_WEBHOOK_SECRET or ''
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 _serialize(payload: dict[str, Any]) -> str:
"""Сериализация JSON для подписи и тела запроса.
Lava подписывает байт-в-байт ту же строку, что и отправляется в теле, поэтому
порядок ключей определяется порядком вставки в payload (Python 3.7 dict сохраняет
порядок). Используем компактный сепаратор и UTF-8 без экранирования юникода.
"""
return json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
def _hmac_hex(self, message: str | bytes, key: str | None = None) -> str:
secret = (key if key is not None else self.secret_key) or ''
msg_bytes = message if isinstance(message, (bytes, bytearray)) else message.encode('utf-8')
return hmac.new(
secret.encode('utf-8'),
msg=msg_bytes,
digestmod=hashlib.sha256,
).hexdigest()
def _build_headers(self, body: str) -> dict[str, str]:
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Signature': self._hmac_hex(body),
}
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
url = f'{self.base_url}/{path.lstrip("/")}'
body = self._serialize(payload)
try:
session = await self._get_session()
async with session.post(url, data=body, headers=self._build_headers(body)) as response:
try:
data = await response.json(content_type=None)
except Exception:
text = await response.text()
data = {'_raw': text}
if not isinstance(data, dict):
data = {'_raw': data}
if response.status >= 400:
error_msg = (
data.get('error')
or (data.get('data') or {}).get('error')
or data.get('message')
or 'Lava API HTTP error'
)
logger.warning(
'Lava API HTTP error',
url=url,
status=response.status,
error_msg=str(error_msg),
code=data.get('code'),
)
raise LavaAPIError(response.status, str(error_msg), data.get('code'))
return data
except aiohttp.ClientError as error:
logger.exception('Lava API connection error', url=url, error=error)
raise
async def create_invoice(
self,
*,
amount_rubles: float,
order_id: str,
success_url: str | None = None,
fail_url: str | None = None,
hook_url: str | None = None,
expire_minutes: int | None = None,
comment: str | None = None,
custom_fields: str | None = None,
include_service: list[str] | None = None,
exclude_service: list[str] | None = None,
) -> dict[str, Any]:
"""Создаёт инвойс через POST /api/v2/invoice/create.
Сумма передаётся в рублях с двумя знаками после запятой.
``orderId`` наш уникальный идентификатор платежа.
"""
# Порядок полей важен (этим же порядком сериализуется и подписывается)
payload: dict[str, Any] = {
'sum': round(float(amount_rubles), 2),
'orderId': str(order_id),
'shopId': self.shop_id,
}
if hook_url:
payload['hookUrl'] = hook_url[:500]
if success_url:
payload['successUrl'] = success_url[:500]
if fail_url:
payload['failUrl'] = fail_url[:500]
if expire_minutes is not None:
# Lava лимит: 1..7200 минут (5 дней)
payload['expire'] = max(1, min(7200, int(expire_minutes)))
if comment:
payload['comment'] = comment[:255]
if custom_fields:
payload['customFields'] = custom_fields[:500]
if include_service:
payload['includeService'] = list(include_service)
if exclude_service:
payload['excludeService'] = list(exclude_service)
logger.info('Lava API invoice/create', order_id=order_id, sum=payload['sum'])
data = await self._post('/api/v2/invoice/create', payload)
# Lava возвращает {"status": "success", "data": {...}} или {"status": "error", "error": "..."}
if isinstance(data.get('status'), str) and data['status'].lower() == 'error':
raise LavaAPIError(200, str(data.get('error') or data.get('message') or 'unknown'))
return data
async def get_invoice_status(
self,
*,
order_id: str | None = None,
invoice_id: str | None = None,
) -> dict[str, Any]:
"""POST /api/v2/invoice/status — статус инвойса по orderId или invoiceId."""
if not order_id and not invoice_id:
raise ValueError('Lava status: order_id or invoice_id required')
payload: dict[str, Any] = {'shopId': self.shop_id}
if invoice_id:
payload['invoiceId'] = str(invoice_id)
if order_id:
payload['orderId'] = str(order_id)
logger.info('Lava API invoice/status', order_id=order_id, invoice_id=invoice_id)
return await self._post('/api/v2/invoice/status', payload)
async def get_services(self) -> dict[str, Any]:
"""POST /api/v2/invoice/services — доступные методы оплаты для shopId."""
payload: dict[str, Any] = {'shopId': self.shop_id}
return await self._post('/api/v2/invoice/services', payload)
def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool:
"""Верификация подписи webhook (заголовок ``Authorization``).
Lava Business webhook подписан HMAC-SHA256 от raw JSON body ключом ``secret_key_2``.
"""
try:
if not received_signature:
logger.warning('Lava webhook: отсутствует Authorization header')
return False
if not self.webhook_secret:
logger.error('Lava webhook: LAVA_WEBHOOK_SECRET не настроен')
return False
# HMAC берётся напрямую от raw bytes — без decode/encode round-trip,
# чтобы не терять байты при некорректной кодировке payload.
expected = self._hmac_hex(raw_body, key=self.webhook_secret)
received = received_signature.strip()
if not hmac.compare_digest(expected.lower(), received.lower()):
logger.warning(
'Lava webhook: invalid signature',
received_prefix=received[:8],
)
return False
return True
except Exception as error:
logger.error('Lava webhook verify error', error=error)
return False
# Singleton instance
lava_service = LavaService()
+62 -6
View File
@@ -373,7 +373,22 @@ class MonitoringService:
user = await get_user_by_id(db, subscription.user_id)
if user and self.bot:
await self._send_subscription_expired_notification(user, subscription, tariff_name=_tariff_name)
# Skip notification if user has another ACTIVE subscription (multi-tariff)
skip_notify = False
if settings.is_multi_tariff_enabled():
other_active = await db.execute(
select(Subscription.id)
.where(
Subscription.user_id == user.id,
Subscription.id != subscription.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > datetime.now(UTC),
)
.limit(1)
)
skip_notify = other_active.scalar_one_or_none() is not None
if not skip_notify:
await self._send_subscription_expired_notification(user, subscription, tariff_name=_tariff_name)
logger.info(
"🔴 Подписка пользователя истекла и статус изменен на 'expired'", user_id=subscription.user_id
@@ -965,8 +980,12 @@ class MonitoringService:
try:
now = datetime.now(UTC)
# Lookback window — don't re-check subscriptions expired more than 30 days ago
lookback = now - timedelta(days=30)
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
@@ -974,7 +993,10 @@ class MonitoringService:
.where(
and_(
Subscription.is_trial == False,
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date <= now,
Subscription.end_date >= lookback,
User.status == UserStatus.ACTIVE.value,
)
)
)
@@ -998,6 +1020,21 @@ class MonitoringService:
if subscription.end_date is None:
continue
# Skip if user has another ACTIVE subscription — they still have service
if settings.is_multi_tariff_enabled():
other_active = await db.execute(
select(Subscription.id)
.where(
Subscription.user_id == user.id,
Subscription.id != subscription.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now,
)
.limit(1)
)
if other_active.scalar_one_or_none() is not None:
continue
time_since_end = now - subscription.end_date
if time_since_end.total_seconds() < 0:
continue
@@ -1007,7 +1044,7 @@ class MonitoringService:
# Day 1 reminder
if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2:
if not await notification_sent(db, user.id, subscription.id, 'expired_1d'):
success = await self._send_expired_day1_notification(user, subscription)
success = await self._send_expired_day1_notification(db, user, subscription)
if success:
await record_notification(db, user.id, subscription.id, 'expired_1d')
sent_day1 += 1
@@ -1090,6 +1127,7 @@ class MonitoringService:
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
@@ -1100,6 +1138,7 @@ class MonitoringService:
Subscription.is_trial == False,
Subscription.end_date > current_time,
Subscription.end_date <= threshold_date,
User.status == UserStatus.ACTIVE.value,
)
)
)
@@ -1772,12 +1811,29 @@ class MonitoringService:
)
return False
async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool:
async def _send_expired_day1_notification(self, db: AsyncSession, user: User, subscription: Subscription) -> bool:
try:
texts = get_texts(user.language)
tariff = getattr(subscription, 'tariff', None)
tariff_label = ''
if settings.is_multi_tariff_enabled() and hasattr(subscription, 'tariff') and subscription.tariff:
tariff_label = f' «{subscription.tariff.name}»'
if settings.is_multi_tariff_enabled() and tariff:
tariff_label = f' «{tariff.name}»'
renewal_period = (tariff.get_shortest_period() if tariff else None) or 30
try:
from app.services.pricing_engine import pricing_engine
pricing = await pricing_engine.calculate_renewal_price(db, subscription, renewal_period, user=user)
renewal_price_kopeks = pricing.final_total
except Exception as price_error:
logger.warning(
'Не удалось рассчитать цену продления для уведомления expired_1d, используем PRICE_30_DAYS',
subscription_id=subscription.id,
user_id=user.id,
error=str(price_error),
)
renewal_price_kopeks = settings.PRICE_30_DAYS
template = texts.get(
'SUBSCRIPTION_EXPIRED_1D',
(
@@ -1787,7 +1843,7 @@ class MonitoringService:
)
message = template.format(
end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'),
price=settings.format_price(settings.PRICE_30_DAYS),
price=settings.format_price(renewal_price_kopeks),
tariff_label=tariff_label,
)
+531
View File
@@ -0,0 +1,531 @@
"""Mixin для интеграции с Antilopay (lk.antilopay.com)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from importlib import import_module
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.antilopay_service import antilopay_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг статусов Antilopay -> internal
ANTILOPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
'PENDING': ('pending', False),
'SUCCESS': ('success', True),
'FAIL': ('failed', False),
'CANCEL': ('cancelled', False),
'EXPIRED': ('expired', False),
'CHARGEBACK': ('chargeback', False),
'REVERSED': ('reversed', False),
}
class AntilopayPaymentMixin:
"""Mixin для работы с платежами Antilopay."""
async def create_antilopay_payment(
self,
db: AsyncSession,
*,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
payment_method_type: str | None = None,
return_url: str | None = None,
) -> dict[str, Any] | None:
"""
Создает платеж Antilopay.
Returns:
Словарь с данными платежа или None при ошибке
"""
if not settings.is_antilopay_enabled():
logger.error('Antilopay не настроен')
return None
# Валидация лимитов
if amount_kopeks < settings.ANTILOPAY_MIN_AMOUNT_KOPEKS:
logger.warning(
'Antilopay: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
ANTILOPAY_MIN_AMOUNT_KOPEKS=settings.ANTILOPAY_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.ANTILOPAY_MAX_AMOUNT_KOPEKS:
logger.warning(
'Antilopay: сумма больше максимальной',
amount_kopeks=amount_kopeks,
ANTILOPAY_MAX_AMOUNT_KOPEKS=settings.ANTILOPAY_MAX_AMOUNT_KOPEKS,
)
return None
# Получаем telegram_id пользователя для order_id
payment_module = import_module('app.services.payment_service')
if user_id is not None:
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
user = None
tg_id = 'guest'
# Генерируем уникальный order_id с telegram_id для удобного поиска
order_id = f'alp{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.ANTILOPAY_CURRENCY
# Метаданные
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
}
try:
# Определяем prefer_methods по типу подметода
prefer_methods: list[str] | None = None
if payment_method_type == 'sbp':
prefer_methods = ['SBP']
elif payment_method_type == 'card':
prefer_methods = ['CARD_RU']
elif payment_method_type == 'sberpay':
prefer_methods = ['SBER_PAY']
# Формируем success/fail URL
result_url = return_url or settings.ANTILOPAY_RETURN_URL
# merchant_extra — строка до 255 символов для callback
merchant_extra = order_id
# Создаем платеж через API
api_result = await antilopay_service.create_payment(
amount_rubles=amount_rubles,
order_id=order_id,
product_name=settings.ANTILOPAY_PRODUCT_NAME,
product_type=settings.ANTILOPAY_PRODUCT_TYPE,
description=description,
customer_email=email,
prefer_methods=prefer_methods,
success_url=result_url,
fail_url=result_url,
merchant_extra=merchant_extra,
)
payment_id = api_result.get('payment_id')
payment_url = api_result.get('payment_url')
logger.info(
'Antilopay: получен ответ API',
order_id=order_id,
payment_id=payment_id,
payment_url=payment_url,
)
lifetime = settings.ANTILOPAY_PAYMENT_LIFETIME_MINUTES
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
# Сохраняем в БД
antilopay_crud = import_module('app.database.crud.antilopay')
local_payment = await antilopay_crud.create_antilopay_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method_type,
antilopay_payment_id=payment_id,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'Antilopay: создан платеж',
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
)
return {
'order_id': order_id,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'payment_url': payment_url,
'payment_id': payment_id,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('Antilopay: ошибка создания платежа', error=e)
return None
async def process_antilopay_callback(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""
Обрабатывает callback от Antilopay.
Подпись проверяется в webserver/payments.py до вызова этого метода.
Args:
db: Сессия БД
payload: JSON тело callback (signature проверена в webserver)
Returns:
True если платеж успешно обработан
"""
try:
callback_type = payload.get('type')
if callback_type != 'payment':
logger.info('Antilopay callback: неизвестный тип', callback_type=callback_type)
return True # Не наш тип — не ошибка
antilopay_payment_id = payload.get('payment_id')
antilopay_status = payload.get('status')
our_order_id = payload.get('order_id')
if not our_order_id or not antilopay_status:
logger.warning('Antilopay callback: отсутствуют обязательные поля', payload=payload)
return False
# Определяем is_paid по статусу
is_confirmed = antilopay_status == 'SUCCESS'
# Ищем платеж по order_id
antilopay_crud = import_module('app.database.crud.antilopay')
payment = await antilopay_crud.get_antilopay_payment_by_order_id(db, our_order_id)
if not payment:
logger.warning(
'Antilopay callback: платеж не найден',
order_id=our_order_id,
)
return False
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await antilopay_crud.get_antilopay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Antilopay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Проверка дублирования (re-check from locked row)
if payment.is_paid:
logger.info('Antilopay callback: платеж уже обработан', order_id=payment.order_id)
return True
# Маппинг статуса
status_info = ANTILOPAY_STATUS_MAP.get(antilopay_status, ('pending', False))
internal_status, is_paid = status_info
# Если статус SUCCESS, принудительно считаем оплаченным
if is_confirmed:
is_paid = True
internal_status = 'success'
callback_payload = {
'antilopay_payment_id': antilopay_payment_id,
'status': antilopay_status,
'amount': payload.get('amount'),
'original_amount': payload.get('original_amount'),
'fee': payload.get('fee'),
'currency': payload.get('currency'),
'pay_method': payload.get('pay_method'),
'pay_data': payload.get('pay_data'),
'customer': payload.get('customer'),
'merchant_extra': payload.get('merchant_extra'),
}
# Проверка суммы ДО обновления статуса
if is_paid:
original_amount = payload.get('original_amount')
if original_amount is not None:
# original_amount в РУБЛЯХ (float), конвертируем в копейки
received_kopeks = round(float(original_amount) * 100)
if abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'Antilopay amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await antilopay_crud.update_antilopay_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
callback_payload=callback_payload,
)
return False
# Финализируем платеж если оплачен — без промежуточного commit
if is_paid:
# Inline field assignments to keep FOR UPDATE lock intact
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.antilopay_payment_id = str(antilopay_payment_id) if antilopay_payment_id else None
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_antilopay_payment(db, payment, trigger='webhook')
# Для не-success статусов можно безопасно коммитить
payment = await antilopay_crud.update_antilopay_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('Antilopay callback: ошибка обработки', error=e)
return False
async def _finalize_antilopay_payment(
self,
db: AsyncSession,
payment: Any,
*,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE lock must be acquired by the caller before invoking this method.
"""
payment_module = import_module('app.services.payment_service')
antilopay_crud = import_module('app.database.crud.antilopay')
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'Antilopay платеж уже связан с транзакцией',
order_id=payment.order_id,
transaction_id=payment.transaction_id,
trigger=trigger,
)
return True
# Read fresh metadata AFTER lock to avoid stale data
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
# --- Guest purchase flow ---
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=payment.order_id,
provider_name='antilopay',
)
if guest_result is not None:
return True
# Ensure paid fields are set (idempotent — caller may have already set them)
if not payment.is_paid:
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
balance_already_credited = bool(metadata.get('balance_credited'))
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error('Пользователь не найден для Antilopay', user_id=payment.user_id)
return False
# Загружаем промогруппы в асинхронном контексте
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
for user_promo_group in getattr(user, 'user_promo_groups', []):
await db.refresh(user_promo_group, attribute_names=['promo_group'])
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
transaction_external_id = payment.order_id
# Проверяем дупликат транзакции
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.ANTILOPAY,
)
display_name = settings.get_antilopay_display_name()
description = f'Пополнение через {display_name}'
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.ANTILOPAY,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await antilopay_crud.link_antilopay_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info('Antilopay платеж уже зачислил баланс ранее', order_id=payment.order_id)
return True
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
# Emit deferred side-effects after atomic commit
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.ANTILOPAY,
external_id=transaction_external_id,
)
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения Antilopay', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, 'bot', None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления Antilopay', error=error)
if getattr(self, 'bot', None) and user.telegram_id:
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
'\u2705 <b>Пополнение успешно!</b>\n\n'
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
f'\U0001f4b3 Способ: {display_name}\n'
f'\U0001f194 Транзакция: {transaction.id}\n\n'
'Баланс пополнен автоматически!'
),
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.error('Ошибка отправки уведомления пользователю Antilopay', error=error)
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
except Exception as error:
logger.error(
'Ошибка при работе с сохраненной корзиной для пользователя',
user_id=payment.user_id,
error=error,
exc_info=True,
)
metadata['balance_change'] = {
'old_balance': old_balance,
'new_balance': user.balance_kopeks,
'credited_at': datetime.now(UTC).isoformat(),
}
metadata['balance_credited'] = True
payment.metadata_json = metadata
await db.commit()
logger.info(
'Обработан Antilopay платеж',
order_id=payment.order_id,
user_id=payment.user_id,
trigger=trigger,
)
return True
async def check_antilopay_payment_status(
self,
db: AsyncSession,
order_id: str,
) -> dict[str, Any] | None:
"""Проверяет статус платежа через API Antilopay."""
try:
result = await antilopay_service.check_payment(order_id=order_id)
return result
except Exception as e:
logger.error('Antilopay: ошибка проверки статуса', order_id=order_id, error=e)
return None
+500
View File
@@ -0,0 +1,500 @@
"""Mixin для интеграции с Donut P2P (gw.donut.business)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from importlib import import_module
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.donut_service import donut_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг description при PayIn (Donut) <-> наш sub-method id
DONUT_METHOD_DESCRIPTIONS: dict[str | None, str] = {
None: 'CARD',
'card': 'CARD',
'sbp': 'SBP',
'sbp_qr': 'SBP_QR',
}
# Маппинг статусов Donut -> internal
DONUT_STATUS_MAP: dict[str, tuple[str, bool]] = {
'created': ('pending', False),
'processing': ('pending', False),
'success': ('success', True),
'cancelled': ('cancelled', False),
'error': ('error', False),
}
class DonutPaymentMixin:
"""Mixin для работы с платежами Donut."""
async def create_donut_payment(
self,
db: AsyncSession,
*,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
payment_method_type: str | None = None,
return_url: str | None = None,
) -> dict[str, Any] | None:
"""Создаёт платёж Donut."""
if not settings.is_donut_enabled():
logger.error('Donut не настроен')
return None
if amount_kopeks < settings.DONUT_MIN_AMOUNT_KOPEKS:
logger.warning(
'Donut: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
DONUT_MIN_AMOUNT_KOPEKS=settings.DONUT_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.DONUT_MAX_AMOUNT_KOPEKS:
logger.warning(
'Donut: сумма больше максимальной',
amount_kopeks=amount_kopeks,
DONUT_MAX_AMOUNT_KOPEKS=settings.DONUT_MAX_AMOUNT_KOPEKS,
)
return None
payment_module = import_module('app.services.payment_service')
if user_id is not None:
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
user = None
tg_id = 'guest'
order_id = f'dnt{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.DONUT_CURRENCY
method_key = (payment_method_type or '').lower() or None
method_description = DONUT_METHOD_DESCRIPTIONS.get(method_key, 'CARD')
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
'payment_method_type': method_key,
'donut_description': method_description,
}
try:
callback_url = self._build_donut_callback_url()
customer_id = str(tg_id) if tg_id != 'guest' else f'guest-{order_id[-6:]}'
actual_return_url = return_url or settings.DONUT_RETURN_URL
api_result = await donut_service.create_payment(
amount_rubles=amount_rubles,
order_id=order_id,
customer_id=customer_id,
method_description=method_description,
customer_email=email,
customer_phone=getattr(user, 'phone', None) if user else None,
callback_url=callback_url,
return_url=actual_return_url,
redirect=True,
)
transaction_id = api_result.get('transaction_id')
details = api_result.get('details') or {}
payment_url = api_result.get('redirect_url') or details.get('qrcode_url') or actual_return_url
logger.info(
'Donut: получен ответ API',
order_id=order_id,
transaction_id=transaction_id,
payment_url=payment_url,
method_description=method_description,
)
lifetime = settings.DONUT_PAYMENT_LIFETIME_MINUTES
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
donut_crud = import_module('app.database.crud.donut')
local_payment = await donut_crud.create_donut_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=method_key,
donut_transaction_id=str(transaction_id) if transaction_id else None,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'Donut: создан платеж',
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
)
return {
'order_id': order_id,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'payment_url': payment_url,
'payment_id': str(transaction_id) if transaction_id else None,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('Donut: ошибка создания платежа', error=e)
return None
@staticmethod
def _build_donut_callback_url() -> str | None:
"""Собирает абсолютный callback URL для Donut."""
webhook_path = settings.DONUT_WEBHOOK_PATH or '/donut-webhook'
base = (
getattr(settings, 'WEBHOOK_URL', None)
or getattr(settings, 'WEB_API_BASE_URL', None)
or getattr(settings, 'CABINET_URL', None)
)
if not base:
return None
return f'{base.rstrip("/")}{webhook_path if webhook_path.startswith("/") else "/" + webhook_path}'
async def process_donut_callback(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""Обрабатывает callback от Donut (подпись уже проверена в webserver)."""
try:
our_order_id = payload.get('order_id')
donut_transaction_id = payload.get('transaction_id')
status_obj = payload.get('status') or {}
donut_status = (status_obj.get('type') or '').strip().lower()
if not our_order_id or not donut_status:
logger.warning('Donut callback: отсутствуют обязательные поля', payload=payload)
return False
donut_crud = import_module('app.database.crud.donut')
payment = await donut_crud.get_donut_payment_by_order_id(db, our_order_id)
if not payment:
logger.warning('Donut callback: платеж не найден', order_id=our_order_id)
return False
locked = await donut_crud.get_donut_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Donut: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
if payment.is_paid:
logger.info('Donut callback: платеж уже обработан', order_id=payment.order_id)
return True
# Терминальные неуспешные статусы стики — провайдер не должен иметь возможность
# «починить» отклонённый/несовпавший платёж повторным callback'ом.
if payment.status in {'amount_mismatch', 'cancelled', 'error', 'expired'}:
logger.warning(
'Donut callback: платёж в терминальном неуспешном статусе, игнорируется',
order_id=payment.order_id,
current_status=payment.status,
incoming_status=donut_status,
)
return True
internal_status, is_paid = DONUT_STATUS_MAP.get(donut_status, ('pending', False))
callback_payload = {
'donut_transaction_id': donut_transaction_id,
'status_type': donut_status,
'amount': payload.get('amount'),
'recalculated': payload.get('recalculated'),
'timestamp': payload.get('timestamp'),
}
if is_paid:
amount_obj = payload.get('amount') or {}
received_value = amount_obj.get('value')
if received_value is not None:
try:
received_kopeks = round(float(received_value) * 100)
except (TypeError, ValueError):
received_kopeks = None
if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'Donut amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await donut_crud.update_donut_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
callback_payload=callback_payload,
)
return False
if is_paid:
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.donut_transaction_id = (
str(donut_transaction_id) if donut_transaction_id else payment.donut_transaction_id
)
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_donut_payment(db, payment, trigger='webhook')
payment = await donut_crud.update_donut_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('Donut callback: ошибка обработки', error=e)
return False
async def _finalize_donut_payment(
self,
db: AsyncSession,
payment: Any,
*,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE lock уже взят вызывающим.
"""
payment_module = import_module('app.services.payment_service')
donut_crud = import_module('app.database.crud.donut')
if payment.transaction_id:
logger.info(
'Donut платеж уже связан с транзакцией',
order_id=payment.order_id,
transaction_id=payment.transaction_id,
trigger=trigger,
)
return True
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=payment.order_id,
provider_name='donut',
)
if guest_result is not None:
return True
if not payment.is_paid:
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
balance_already_credited = bool(metadata.get('balance_credited'))
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error('Пользователь не найден для Donut', user_id=payment.user_id)
return False
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
for user_promo_group in getattr(user, 'user_promo_groups', []):
await db.refresh(user_promo_group, attribute_names=['promo_group'])
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
transaction_external_id = payment.order_id
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.DONUT,
)
display_name = settings.get_donut_display_name()
description = f'Пополнение через {display_name}'
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.DONUT,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await donut_crud.link_donut_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info('Donut платеж уже зачислил баланс ранее', order_id=payment.order_id)
return True
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.DONUT,
external_id=transaction_external_id,
)
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения Donut', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, 'bot', None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления Donut', error=error)
if getattr(self, 'bot', None) and user.telegram_id:
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
'✅ <b>Пополнение успешно!</b>\n\n'
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
f'\U0001f4b3 Способ: {display_name}\n'
f'\U0001f194 Транзакция: {transaction.id}\n\n'
'Баланс пополнен автоматически!'
),
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.error('Ошибка отправки уведомления пользователю Donut', error=error)
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
except Exception as error:
logger.error(
'Ошибка при работе с сохраненной корзиной для пользователя',
user_id=payment.user_id,
error=error,
exc_info=True,
)
metadata['balance_change'] = {
'old_balance': old_balance,
'new_balance': user.balance_kopeks,
'credited_at': datetime.now(UTC).isoformat(),
}
metadata['balance_credited'] = True
payment.metadata_json = metadata
await db.commit()
logger.info(
'Обработан Donut платеж',
order_id=payment.order_id,
user_id=payment.user_id,
trigger=trigger,
)
return True
async def check_donut_payment_status(
self,
db: AsyncSession,
transaction_id: str,
) -> dict[str, Any] | None:
"""Запрос статуса платежа через API Donut."""
try:
return await donut_service.check_payment(transaction_id=transaction_id)
except Exception as e:
logger.error('Donut: ошибка проверки статуса', transaction_id=transaction_id, error=e)
return None
+518
View File
@@ -0,0 +1,518 @@
"""Mixin для интеграции с Etoplatezhi (paymentpage.etoplatezhi.ru)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from importlib import import_module
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.etoplatezhi_service import etoplatezhi_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг статусов Etoplatezhi -> internal
ETOPLATEZHI_STATUS_MAP: dict[str, tuple[str, bool]] = {
'success': ('success', True),
'decline': ('declined', False),
'error': ('error', False),
'processing': ('pending', False),
'awaiting 3ds result': ('pending', False),
'awaiting redirect result': ('pending', False),
'awaiting clarification': ('pending', False),
'awaiting customer action': ('pending', False),
'cancelled': ('cancelled', False),
'refunded': ('refunded', False),
'partially refunded': ('partially_refunded', False),
'reversed': ('reversed', False),
}
class EtoplatezhiPaymentMixin:
"""Mixin для работы с платежами Etoplatezhi."""
async def create_etoplatezhi_payment(
self,
db: AsyncSession,
*,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
payment_method_type: str | None = None,
return_url: str | None = None,
) -> dict[str, Any] | None:
"""
Создает платеж Etoplatezhi.
Returns:
Словарь с данными платежа или None при ошибке
"""
if not settings.is_etoplatezhi_enabled():
logger.error('Etoplatezhi не настроен')
return None
# Валидация лимитов
if amount_kopeks < settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS:
logger.warning(
'Etoplatezhi: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
ETOPLATEZHI_MIN_AMOUNT_KOPEKS=settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS:
logger.warning(
'Etoplatezhi: сумма больше максимальной',
amount_kopeks=amount_kopeks,
ETOPLATEZHI_MAX_AMOUNT_KOPEKS=settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS,
)
return None
# Получаем telegram_id пользователя для order_id
payment_module = import_module('app.services.payment_service')
if user_id is not None:
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
user = None
tg_id = 'guest'
# Генерируем уникальный order_id с telegram_id для удобного поиска
order_id = f'etp{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.ETOPLATEZHI_CURRENCY
# Метаданные
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
}
try:
# Формируем webhook URL
webhook_url = None
if settings.WEBHOOK_URL:
webhook_url = f'{settings.WEBHOOK_URL.rstrip("/")}{settings.ETOPLATEZHI_WEBHOOK_PATH}'
lifetime = settings.ETOPLATEZHI_PAYMENT_LIFETIME_MINUTES
# Определяем force_payment_method по типу подметода
force_method = None
if payment_method_type == 'sbp':
force_method = 'sbp'
elif payment_method_type == 'card':
force_method = 'card'
# Строим URL для редиректа на платёжную страницу
payment_url = etoplatezhi_service.build_payment_url(
project_id=settings.ETOPLATEZHI_PROJECT_ID or 0,
payment_id=order_id,
payment_amount=amount_kopeks,
payment_currency=currency,
customer_id=str(tg_id),
description=description,
callback_url=webhook_url,
success_url=return_url or settings.ETOPLATEZHI_RETURN_URL,
fail_url=return_url or settings.ETOPLATEZHI_RETURN_URL,
force_payment_method=force_method,
customer_email=email,
language_code=language,
)
logger.info(
'Etoplatezhi: сформирован URL платежа',
order_id=order_id,
payment_url=payment_url,
)
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
# Сохраняем в БД
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
local_payment = await etoplatezhi_crud.create_etoplatezhi_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method_type,
etoplatezhi_payment_id=None,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'Etoplatezhi: создан платеж',
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
)
return {
'order_id': order_id,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'payment_url': payment_url,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('Etoplatezhi: ошибка создания платежа', error=e)
return None
async def process_etoplatezhi_callback(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""
Обрабатывает callback от Etoplatezhi.
Подпись проверяется в webserver/payments.py до вызова этого метода.
Args:
db: Сессия БД
payload: JSON тело callback (signature проверена в webserver)
Returns:
True если платеж успешно обработан
"""
try:
# Etoplatezhi callback structure:
# {project_id, payment: {id, status, sum: {amount, currency}}, customer: {id}, signature}
payment_data = payload.get('payment', {})
etoplatezhi_payment_id = payment_data.get('id')
etoplatezhi_status = payment_data.get('status')
# payment.id в callback — это наш payment_id (order_id)
our_payment_id = str(etoplatezhi_payment_id) if etoplatezhi_payment_id else None
if not our_payment_id or not etoplatezhi_status:
logger.warning('Etoplatezhi callback: отсутствуют обязательные поля', payload=payload)
return False
# Определяем is_paid по статусу
is_confirmed = etoplatezhi_status == 'success'
# Ищем платеж по order_id (наш payment_id = order_id)
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
payment = await etoplatezhi_crud.get_etoplatezhi_payment_by_order_id(db, our_payment_id)
if not payment:
logger.warning(
'Etoplatezhi callback: платеж не найден',
payment_id=our_payment_id,
)
return False
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await etoplatezhi_crud.get_etoplatezhi_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Etoplatezhi: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Проверка дублирования (re-check from locked row)
if payment.is_paid:
logger.info('Etoplatezhi callback: платеж уже обработан', order_id=payment.order_id)
return True
# Маппинг статуса
status_info = ETOPLATEZHI_STATUS_MAP.get(etoplatezhi_status, ('pending', False))
internal_status, is_paid = status_info
# Если статус success, принудительно считаем оплаченным
if is_confirmed:
is_paid = True
internal_status = 'success'
# Извлекаем сумму из callback: payment.sum.amount (в минорных единицах)
sum_data = payment_data.get('sum', {})
callback_payload = {
'etoplatezhi_payment_id': etoplatezhi_payment_id,
'status': etoplatezhi_status,
'sum': sum_data,
'customer': payload.get('customer'),
'project_id': payload.get('project_id'),
}
# Проверка суммы ДО обновления статуса
if is_paid:
amount_value = sum_data.get('amount')
if amount_value is not None:
# amount в минорных единицах (копейках)
received_kopeks = int(amount_value)
if abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'Etoplatezhi amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await etoplatezhi_crud.update_etoplatezhi_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
callback_payload=callback_payload,
)
return False
# Финализируем платеж если оплачен — без промежуточного commit
if is_paid:
# Inline field assignments to keep FOR UPDATE lock intact
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.etoplatezhi_payment_id = str(etoplatezhi_payment_id) if etoplatezhi_payment_id else None
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_etoplatezhi_payment(db, payment, trigger='webhook')
# Для не-success статусов можно безопасно коммитить
payment = await etoplatezhi_crud.update_etoplatezhi_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('Etoplatezhi callback: ошибка обработки', error=e)
return False
async def _finalize_etoplatezhi_payment(
self,
db: AsyncSession,
payment: Any,
*,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE lock must be acquired by the caller before invoking this method.
"""
payment_module = import_module('app.services.payment_service')
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'Etoplatezhi платеж уже связан с транзакцией',
order_id=payment.order_id,
transaction_id=payment.transaction_id,
trigger=trigger,
)
return True
# Read fresh metadata AFTER lock to avoid stale data
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
# --- Guest purchase flow ---
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=payment.order_id,
provider_name='etoplatezhi',
)
if guest_result is not None:
return True
# Ensure paid fields are set (idempotent — caller may have already set them)
if not payment.is_paid:
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
balance_already_credited = bool(metadata.get('balance_credited'))
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error('Пользователь не найден для Etoplatezhi', user_id=payment.user_id)
return False
# Загружаем промогруппы в асинхронном контексте
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
for user_promo_group in getattr(user, 'user_promo_groups', []):
await db.refresh(user_promo_group, attribute_names=['promo_group'])
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
transaction_external_id = payment.order_id
# Проверяем дупликат транзакции
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.ETOPLATEZHI,
)
display_name = settings.get_etoplatezhi_display_name()
description = f'Пополнение через {display_name}'
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.ETOPLATEZHI,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await etoplatezhi_crud.link_etoplatezhi_payment_to_transaction(
db, payment=payment, transaction_id=transaction.id
)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info('Etoplatezhi платеж уже зачислил баланс ранее', order_id=payment.order_id)
return True
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
# Emit deferred side-effects after atomic commit
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.ETOPLATEZHI,
external_id=transaction_external_id,
)
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения Etoplatezhi', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, 'bot', None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления Etoplatezhi', error=error)
if getattr(self, 'bot', None) and user.telegram_id:
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
'\u2705 <b>Пополнение успешно!</b>\n\n'
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
f'\U0001f4b3 Способ: {display_name}\n'
f'\U0001f194 Транзакция: {transaction.id}\n\n'
'Баланс пополнен автоматически!'
),
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.error('Ошибка отправки уведомления пользователю Etoplatezhi', error=error)
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
except Exception as error:
logger.error(
'Ошибка при работе с сохраненной корзиной для пользователя',
user_id=payment.user_id,
error=error,
exc_info=True,
)
metadata['balance_change'] = {
'old_balance': old_balance,
'new_balance': user.balance_kopeks,
'credited_at': datetime.now(UTC).isoformat(),
}
metadata['balance_credited'] = True
payment.metadata_json = metadata
await db.commit()
logger.info(
'Обработан Etoplatezhi платеж',
order_id=payment.order_id,
user_id=payment.user_id,
trigger=trigger,
)
return True
+494
View File
@@ -0,0 +1,494 @@
"""Mixin для интеграции с Jupiter (FPGate P2P v2.1, app.juppiter.tech)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from importlib import import_module
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.jupiter_service import jupiter_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг статусов Jupiter -> internal
JUPITER_STATUS_MAP: dict[str, tuple[str, bool]] = {
'success': ('success', True),
'processing': ('pending', False),
'cancelled': ('cancelled', False),
'declined': ('declined', False),
'error': ('error', False),
}
class JupiterPaymentMixin:
"""Mixin для работы с платежами Jupiter."""
async def create_jupiter_payment(
self,
db: AsyncSession,
*,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
payment_method_type: str | None = None,
return_url: str | None = None,
) -> dict[str, Any] | None:
"""Создаёт платёж Jupiter.
Параметр ``return_url`` принимается для совместимости сигнатуры с другими провайдерами
(cabinet/routes/balance.py одинаково вызывает все ``create_*_payment``), но фактически
не используется: спецификация Jupiter v2.1 имеет поле ``redirect`` зарезервированное
для будущего использования и не поддерживает return-URL семантику. Пользователь
видит QR-код СБП и подтверждает оплату в банковском приложении.
"""
if not settings.is_jupiter_enabled():
logger.error('Jupiter не настроен')
return None
if amount_kopeks < settings.JUPITER_MIN_AMOUNT_KOPEKS:
logger.warning(
'Jupiter: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
JUPITER_MIN_AMOUNT_KOPEKS=settings.JUPITER_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.JUPITER_MAX_AMOUNT_KOPEKS:
logger.warning(
'Jupiter: сумма больше максимальной',
amount_kopeks=amount_kopeks,
JUPITER_MAX_AMOUNT_KOPEKS=settings.JUPITER_MAX_AMOUNT_KOPEKS,
)
return None
payment_module = import_module('app.services.payment_service')
if user_id is not None:
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
user = None
tg_id = 'guest'
order_id = f'jup{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.JUPITER_CURRENCY
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
'payment_method_type': payment_method_type,
}
try:
callback_url = self._build_jupiter_callback_url()
customer_id = str(tg_id) if tg_id != 'guest' else f'guest-{order_id[-6:]}'
customer_name = (
getattr(user, 'first_name', None) or getattr(user, 'username', None) or settings.JUPITER_FALLBACK_NAME
)
api_result = await jupiter_service.create_payment(
amount_rubles=amount_rubles,
order_id=order_id,
customer_id=customer_id,
customer_email=email,
customer_name=customer_name,
callback_url=callback_url,
description=description[:255] if description else None,
)
transaction_id = api_result.get('transaction_id')
details = api_result.get('details') or {}
payment_url = details.get('qrcode_url') or details.get('url')
logger.info(
'Jupiter: получен ответ API',
order_id=order_id,
transaction_id=transaction_id,
payment_url=payment_url,
)
lifetime = settings.JUPITER_PAYMENT_LIFETIME_MINUTES
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
jupiter_crud = import_module('app.database.crud.jupiter')
local_payment = await jupiter_crud.create_jupiter_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method_type,
jupiter_transaction_id=str(transaction_id) if transaction_id else None,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'Jupiter: создан платеж',
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
)
return {
'order_id': order_id,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'payment_url': payment_url,
'payment_id': str(transaction_id) if transaction_id else None,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('Jupiter: ошибка создания платежа', error=e)
return None
@staticmethod
def _build_jupiter_callback_url() -> str | None:
"""Собирает абсолютный callback URL для Jupiter."""
webhook_path = settings.JUPITER_WEBHOOK_PATH or '/jupiter-webhook'
base = (
getattr(settings, 'WEBHOOK_URL', None)
or getattr(settings, 'WEB_API_BASE_URL', None)
or getattr(settings, 'CABINET_URL', None)
)
if not base:
return None
return f'{base.rstrip("/")}{webhook_path if webhook_path.startswith("/") else "/" + webhook_path}'
async def process_jupiter_callback(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""Обрабатывает callback от Jupiter (подпись уже проверена в webserver)."""
try:
our_order_id = payload.get('order_id')
jupiter_transaction_id = payload.get('transaction_id')
status_obj = payload.get('status') or {}
jupiter_status = (status_obj.get('type') or '').strip().lower()
if not our_order_id or not jupiter_status:
logger.warning('Jupiter callback: отсутствуют обязательные поля', payload=payload)
return False
jupiter_crud = import_module('app.database.crud.jupiter')
payment = await jupiter_crud.get_jupiter_payment_by_order_id(db, our_order_id)
if not payment:
logger.warning('Jupiter callback: платеж не найден', order_id=our_order_id)
return False
locked = await jupiter_crud.get_jupiter_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Jupiter: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
if payment.is_paid:
logger.info('Jupiter callback: платеж уже обработан', order_id=payment.order_id)
return True
# Терминальные неуспешные статусы стики — провайдер не должен иметь возможность
# «починить» отклонённый/несовпавший платёж повторным callback'ом.
if payment.status in {'amount_mismatch', 'cancelled', 'declined', 'error', 'expired'}:
logger.warning(
'Jupiter callback: платёж в терминальном неуспешном статусе, игнорируется',
order_id=payment.order_id,
current_status=payment.status,
incoming_status=jupiter_status,
)
return True
internal_status, is_paid = JUPITER_STATUS_MAP.get(jupiter_status, ('pending', False))
callback_payload = {
'jupiter_transaction_id': jupiter_transaction_id,
'status_type': jupiter_status,
'amount': payload.get('amount'),
'recalculated': payload.get('recalculated'),
'timestamp': payload.get('timestamp'),
}
# Сверяем сумму ДО обновления статуса
if is_paid:
amount_obj = payload.get('amount') or {}
received_value = amount_obj.get('value')
if received_value is not None:
try:
received_kopeks = round(float(received_value) * 100)
except (TypeError, ValueError):
received_kopeks = None
if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'Jupiter amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await jupiter_crud.update_jupiter_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
callback_payload=callback_payload,
)
return False
if is_paid:
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.jupiter_transaction_id = (
str(jupiter_transaction_id) if jupiter_transaction_id else payment.jupiter_transaction_id
)
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_jupiter_payment(db, payment, trigger='webhook')
payment = await jupiter_crud.update_jupiter_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('Jupiter callback: ошибка обработки', error=e)
return False
async def _finalize_jupiter_payment(
self,
db: AsyncSession,
payment: Any,
*,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE lock уже взят вызывающим.
"""
payment_module = import_module('app.services.payment_service')
jupiter_crud = import_module('app.database.crud.jupiter')
if payment.transaction_id:
logger.info(
'Jupiter платеж уже связан с транзакцией',
order_id=payment.order_id,
transaction_id=payment.transaction_id,
trigger=trigger,
)
return True
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=payment.order_id,
provider_name='jupiter',
)
if guest_result is not None:
return True
if not payment.is_paid:
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
balance_already_credited = bool(metadata.get('balance_credited'))
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error('Пользователь не найден для Jupiter', user_id=payment.user_id)
return False
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
for user_promo_group in getattr(user, 'user_promo_groups', []):
await db.refresh(user_promo_group, attribute_names=['promo_group'])
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
transaction_external_id = payment.order_id
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.JUPITER,
)
display_name = settings.get_jupiter_display_name()
description = f'Пополнение через {display_name}'
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.JUPITER,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await jupiter_crud.link_jupiter_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info('Jupiter платеж уже зачислил баланс ранее', order_id=payment.order_id)
return True
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.JUPITER,
external_id=transaction_external_id,
)
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения Jupiter', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, 'bot', None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления Jupiter', error=error)
if getattr(self, 'bot', None) and user.telegram_id:
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
'✅ <b>Пополнение успешно!</b>\n\n'
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
f'\U0001f4b3 Способ: {display_name}\n'
f'\U0001f194 Транзакция: {transaction.id}\n\n'
'Баланс пополнен автоматически!'
),
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.error('Ошибка отправки уведомления пользователю Jupiter', error=error)
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
except Exception as error:
logger.error(
'Ошибка при работе с сохраненной корзиной для пользователя',
user_id=payment.user_id,
error=error,
exc_info=True,
)
metadata['balance_change'] = {
'old_balance': old_balance,
'new_balance': user.balance_kopeks,
'credited_at': datetime.now(UTC).isoformat(),
}
metadata['balance_credited'] = True
payment.metadata_json = metadata
await db.commit()
logger.info(
'Обработан Jupiter платеж',
order_id=payment.order_id,
user_id=payment.user_id,
trigger=trigger,
)
return True
async def check_jupiter_payment_status(
self,
db: AsyncSession,
transaction_id: str,
) -> dict[str, Any] | None:
"""Запрос статуса платежа через API Jupiter."""
try:
return await jupiter_service.check_payment(transaction_id=transaction_id)
except Exception as e:
logger.error('Jupiter: ошибка проверки статуса', transaction_id=transaction_id, error=e)
return None
+590
View File
@@ -0,0 +1,590 @@
"""Mixin для интеграции с Lava Business (gate.lava.ru)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from importlib import import_module
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.lava_service import lava_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг sub-method -> includeService для фильтрации методов на странице оплаты Lava
LAVA_INCLUDE_SERVICE_MAP: dict[str | None, list[str] | None] = {
None: None,
'card': ['card'],
'sbp': ['sbp'],
}
# Маппинг статусов Lava -> internal
LAVA_STATUS_MAP: dict[str, tuple[str, bool]] = {
'created': ('pending', False),
'pending': ('pending', False),
'processing': ('pending', False), # на случай промежуточного статуса
'success': ('success', True),
'cancel': ('cancelled', False),
'cancelled': ('cancelled', False),
'expired': ('expired', False),
'error': ('error', False),
'failed': ('failed', False),
}
class LavaPaymentMixin:
"""Mixin для работы с платежами Lava Business."""
async def create_lava_payment(
self,
db: AsyncSession,
*,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
payment_method_type: str | None = None,
return_url: str | None = None,
) -> dict[str, Any] | None:
"""Создаёт инвойс Lava."""
if not settings.is_lava_enabled():
logger.error('Lava не настроен')
return None
if amount_kopeks < settings.LAVA_MIN_AMOUNT_KOPEKS:
logger.warning(
'Lava: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
LAVA_MIN_AMOUNT_KOPEKS=settings.LAVA_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.LAVA_MAX_AMOUNT_KOPEKS:
logger.warning(
'Lava: сумма больше максимальной',
amount_kopeks=amount_kopeks,
LAVA_MAX_AMOUNT_KOPEKS=settings.LAVA_MAX_AMOUNT_KOPEKS,
)
return None
payment_module = import_module('app.services.payment_service')
if user_id is not None:
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
user = None
tg_id = 'guest'
# 32 hex char (128 бит) суффикс — order_id уникален даже при публичном tg_id
order_id = f'lava{tg_id}_{uuid.uuid4().hex}'
amount_rubles = amount_kopeks / 100
currency = settings.LAVA_CURRENCY
method_key = (payment_method_type or '').lower() or None
include_service = LAVA_INCLUDE_SERVICE_MAP.get(method_key)
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
'payment_method_type': method_key,
'email': email,
}
try:
hook_url = self._build_lava_hook_url()
if not hook_url:
logger.warning(
'Lava: hook_url не сконфигурирован — '
'платёж создаётся, но автоматическое подтверждение через webhook невозможно. '
'Установите WEBHOOK_URL / WEB_API_BASE_URL / CABINET_URL.'
)
actual_return_url = return_url or settings.LAVA_RETURN_URL
api_result = await lava_service.create_invoice(
amount_rubles=amount_rubles,
order_id=order_id,
hook_url=hook_url,
success_url=actual_return_url,
fail_url=actual_return_url,
expire_minutes=settings.LAVA_PAYMENT_LIFETIME_MINUTES,
comment=(description or '')[:255] or None,
custom_fields=str(user_id) if user_id is not None else None,
include_service=include_service,
)
data = (api_result.get('data') or api_result) if isinstance(api_result, dict) else {}
lava_invoice_id = data.get('id') or data.get('invoice_id')
payment_url = data.get('url') or data.get('payment_url')
expired_str = data.get('expired')
if not payment_url:
# Без URL у пользователя нет способа оплатить — это аномалия Lava API.
# Row не сохраняем, чтобы не плодить «зависшие» pending-инвойсы без реквизитов.
logger.error(
'Lava: ответ API без payment URL, инвойс не создан',
order_id=order_id,
lava_invoice_id=lava_invoice_id,
response_keys=list(data.keys()) if isinstance(data, dict) else None,
)
return None
logger.info(
'Lava: получен ответ API',
order_id=order_id,
lava_invoice_id=lava_invoice_id,
payment_url=payment_url,
)
lifetime = settings.LAVA_PAYMENT_LIFETIME_MINUTES
expires_at = self._parse_lava_expired(expired_str) or (datetime.now(UTC) + timedelta(minutes=lifetime))
lava_crud = import_module('app.database.crud.lava')
local_payment = await lava_crud.create_lava_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=method_key,
lava_invoice_id=str(lava_invoice_id) if lava_invoice_id else None,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'Lava: создан платеж',
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
)
return {
'order_id': order_id,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'payment_url': payment_url,
'payment_id': str(lava_invoice_id) if lava_invoice_id else None,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('Lava: ошибка создания платежа', error=e)
return None
@staticmethod
def _parse_lava_expired(value: Any) -> datetime | None:
"""Парсит поле ``expired`` из ответа Lava.
Принимаются только TZ-aware строки (ISO с offset/Z) или unix timestamp.
Naive-строки игнорируются TZ Lava в спеке не задокументирована,
а угадывание UTC может сместить срок жизни инвойса на несколько часов.
Если парсинг не удался caller использует fallback ``now + lifetime``.
"""
if value is None or value == '':
return None
if isinstance(value, (int, float)):
try:
return datetime.fromtimestamp(float(value), tz=UTC)
except (ValueError, OSError):
return None
if isinstance(value, str):
try:
from dateutil.parser import isoparse # type: ignore[import-not-found]
parsed = isoparse(value)
if parsed.tzinfo is None:
return None # без TZ доверять не можем
return parsed
except Exception:
return None
return None
@staticmethod
def _build_lava_hook_url() -> str | None:
"""Собирает абсолютный URL вебхука для Lava."""
webhook_path = settings.LAVA_WEBHOOK_PATH or '/lava-webhook'
base = (
getattr(settings, 'WEBHOOK_URL', None)
or getattr(settings, 'WEB_API_BASE_URL', None)
or getattr(settings, 'CABINET_URL', None)
)
if not base:
return None
suffix = webhook_path if webhook_path.startswith('/') else f'/{webhook_path}'
return f'{base.rstrip("/")}{suffix}'
async def process_lava_callback(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""Обрабатывает webhook от Lava (подпись уже проверена в webserver)."""
try:
lava_invoice_id = payload.get('invoice_id')
our_order_id = payload.get('order_id')
lava_status = (payload.get('status') or '').strip().lower()
pay_service = payload.get('pay_service')
if not our_order_id or not lava_status:
logger.warning('Lava webhook: отсутствуют обязательные поля')
return False
lava_crud = import_module('app.database.crud.lava')
payment = await lava_crud.get_lava_payment_by_order_id(db, our_order_id)
if not payment:
# Fallback по invoice_id, но строго проверяем совпадение order_id
if lava_invoice_id:
payment = await lava_crud.get_lava_payment_by_invoice_id(db, str(lava_invoice_id))
if payment and payment.order_id != our_order_id:
logger.error(
'Lava webhook: order_id mismatch',
webhook_order_id=our_order_id,
record_order_id=payment.order_id,
invoice_id=lava_invoice_id,
)
return False
if not payment:
logger.warning('Lava webhook: платеж не найден', order_id=our_order_id)
return False
locked = await lava_crud.get_lava_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Lava: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
if payment.is_paid:
logger.info('Lava webhook: платеж уже обработан', order_id=payment.order_id)
return True
# Терминальные неуспешные статусы — стики, защита от повторного успеха
if payment.status in {'amount_mismatch', 'cancelled', 'cancel', 'error', 'expired', 'failed'}:
# Если внезапно пришёл success после терминальной неудачи — это сигнал
# подделки или ошибки на стороне Lava, эскалируем.
if lava_status == 'success':
logger.error(
'Lava webhook: success на терминально-неуспешном платеже, игнорируется',
order_id=payment.order_id,
current_status=payment.status,
)
else:
logger.warning(
'Lava webhook: платёж в терминальном неуспешном статусе, игнорируется',
order_id=payment.order_id,
current_status=payment.status,
incoming_status=lava_status,
)
return True
if lava_status not in LAVA_STATUS_MAP:
logger.warning(
'Lava webhook: неизвестный статус, обрабатываем как pending',
order_id=payment.order_id,
incoming_status=lava_status,
)
internal_status, is_paid = LAVA_STATUS_MAP.get(lava_status, ('pending', False))
callback_payload = {
'lava_invoice_id': lava_invoice_id,
'status': lava_status,
'amount': payload.get('amount'),
'credited': payload.get('credited'),
'pay_service': pay_service,
'pay_time': payload.get('pay_time'),
'payer_details': payload.get('payer_details'),
'custom_fields': payload.get('custom_fields'),
}
# Сверяем сумму ДО зачисления
if is_paid:
# Lava webhook содержит amount (сумма счёта в рублях, float).
# Сверяем с тем, что мы отправляли на создание.
received_amount = payload.get('amount')
if received_amount is not None:
try:
received_kopeks = round(float(received_amount) * 100)
except (TypeError, ValueError):
received_kopeks = None
if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'Lava amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await lava_crud.update_lava_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
callback_payload=callback_payload,
)
return False
if is_paid:
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
if lava_invoice_id and not payment.lava_invoice_id:
payment.lava_invoice_id = str(lava_invoice_id)
# Сохраняем pay_service в metadata, не перезаписывая user-выбранный payment_method
if pay_service:
metadata_now = dict(getattr(payment, 'metadata_json', {}) or {})
metadata_now['actual_pay_service'] = str(pay_service).lower()
payment.metadata_json = metadata_now
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_lava_payment(db, payment, trigger='webhook')
payment = await lava_crud.update_lava_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('Lava webhook: ошибка обработки', error=e)
return False
async def _finalize_lava_payment(
self,
db: AsyncSession,
payment: Any,
*,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE-lock уже взят вызывающим.
"""
payment_module = import_module('app.services.payment_service')
lava_crud = import_module('app.database.crud.lava')
if payment.transaction_id:
logger.info(
'Lava платеж уже связан с транзакцией',
order_id=payment.order_id,
transaction_id=payment.transaction_id,
trigger=trigger,
)
return True
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=payment.order_id,
provider_name='lava',
)
if guest_result is not None:
return True
if not payment.is_paid:
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
balance_already_credited = bool(metadata.get('balance_credited'))
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error('Пользователь не найден для Lava', user_id=payment.user_id)
return False
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
for user_promo_group in getattr(user, 'user_promo_groups', []):
await db.refresh(user_promo_group, attribute_names=['promo_group'])
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
transaction_external_id = payment.order_id
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.LAVA,
)
display_name = settings.get_lava_display_name()
description = f'Пополнение через {display_name}'
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.LAVA,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await lava_crud.link_lava_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info('Lava платеж уже зачислил баланс ранее', order_id=payment.order_id)
return True
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.LAVA,
external_id=transaction_external_id,
)
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения Lava', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, 'bot', None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления Lava', error=error)
if getattr(self, 'bot', None) and user.telegram_id:
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
'✅ <b>Пополнение успешно!</b>\n\n'
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
f'\U0001f4b3 Способ: {display_name}\n'
f'\U0001f194 Транзакция: {transaction.id}\n\n'
'Баланс пополнен автоматически!'
),
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.error('Ошибка отправки уведомления пользователю Lava', error=error)
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
except Exception as error:
logger.error(
'Ошибка при работе с сохраненной корзиной для пользователя',
user_id=payment.user_id,
error=error,
exc_info=True,
)
metadata['balance_change'] = {
'old_balance': old_balance,
'new_balance': user.balance_kopeks,
'credited_at': datetime.now(UTC).isoformat(),
}
metadata['balance_credited'] = True
payment.metadata_json = metadata
await db.commit()
logger.info(
'Обработан Lava платеж',
order_id=payment.order_id,
user_id=payment.user_id,
trigger=trigger,
)
return True
async def check_lava_payment_status(
self,
db: AsyncSession,
order_id: str | None = None,
invoice_id: str | None = None,
) -> dict[str, Any] | None:
"""Запрос статуса инвойса через API Lava."""
try:
return await lava_service.get_invoice_status(order_id=order_id, invoice_id=invoice_id)
except Exception as e:
logger.error(
'Lava: ошибка проверки статуса',
order_id=order_id,
invoice_id=invoice_id,
error=e,
)
return None
+4 -4
View File
@@ -558,8 +558,8 @@ class Pal24PaymentMixin:
payment_id_str = str(payment.payment_id)
try:
payment_response = await service.get_payment_status(payment_id_str)
except Pal24APIError as error:
logger.error('Ошибка Pal24 API при получении статуса платежа', error=error)
except Pal24APIError:
logger.debug('Pal24 payment_id не найден или невалиден', payment_id=payment_id_str)
else:
if payment_response:
remote_payloads['payment_status'] = payment_response
@@ -569,8 +569,8 @@ class Pal24PaymentMixin:
try:
payments_response = await service.get_bill_payments(bill_id_str)
except Pal24APIError as error:
logger.error('Ошибка Pal24 API при получении списка платежей', error=error)
except Pal24APIError:
logger.debug('Pal24 bill payments не найдены', bill_id=bill_id_str)
else:
if payments_response:
remote_payloads['bill_payments'] = payments_response
@@ -189,6 +189,57 @@ def _get_method_defaults() -> dict:
{'id': 'sbp', 'name': 'СБП'},
],
},
'etoplatezhi': {
'default_display_name': settings.get_etoplatezhi_display_name(),
'is_configured': settings.is_etoplatezhi_enabled(),
'default_min': settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS,
'default_max': settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'card', 'name': 'Карта'},
{'id': 'sbp', 'name': 'СБП'},
],
},
'antilopay': {
'default_display_name': settings.get_antilopay_display_name(),
'is_configured': settings.is_antilopay_enabled(),
'default_min': settings.ANTILOPAY_MIN_AMOUNT_KOPEKS,
'default_max': settings.ANTILOPAY_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'card', 'name': 'Карта'},
{'id': 'sbp', 'name': 'СБП'},
{'id': 'sberpay', 'name': 'SberPay'},
],
},
'jupiter': {
'default_display_name': settings.get_jupiter_display_name(),
'is_configured': settings.is_jupiter_enabled(),
'default_min': settings.JUPITER_MIN_AMOUNT_KOPEKS,
'default_max': settings.JUPITER_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'sbp', 'name': 'СБП'},
],
},
'donut': {
'default_display_name': settings.get_donut_display_name(),
'is_configured': settings.is_donut_enabled(),
'default_min': settings.DONUT_MIN_AMOUNT_KOPEKS,
'default_max': settings.DONUT_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'card', 'name': 'Карта'},
{'id': 'sbp', 'name': 'СБП'},
{'id': 'sbp_qr', 'name': 'СБП QR'},
],
},
'lava': {
'default_display_name': settings.get_lava_display_name(),
'is_configured': settings.is_lava_enabled(),
'default_min': settings.LAVA_MIN_AMOUNT_KOPEKS,
'default_max': settings.LAVA_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'card', 'name': 'Карта'},
{'id': 'sbp', 'name': 'СБП'},
],
},
}
@@ -235,6 +286,11 @@ DEFAULT_METHOD_ORDER = [
'rollypay',
'overpay',
'aurapay',
'etoplatezhi',
'antilopay',
'jupiter',
'donut',
'lava',
]
+290
View File
@@ -15,17 +15,25 @@ from sqlalchemy.orm import selectinload
from sqlalchemy.types import String as SAString
from app.database.models import (
AntilopayPayment,
AuraPayPayment,
CloudPaymentsPayment,
CryptoBotPayment,
DonutPayment,
EtoplatezhiPayment,
FreekassaPayment,
HeleketPayment,
JupiterPayment,
KassaAiPayment,
LavaPayment,
MulenPayPayment,
OverpayPayment,
Pal24Payment,
PaymentMethod,
PayPearPayment,
PlategaPayment,
RioPayPayment,
RollyPayPayment,
SeverPayPayment,
Transaction,
TransactionType,
@@ -683,6 +691,280 @@ async def _search_overpay(db: AsyncSession, params: SearchParams) -> list[Pendin
return records
async def _search_paypear(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(PayPearPayment).options(selectinload(PayPearPayment.user)).order_by(desc(PayPearPayment.created_at))
stmt = _apply_date_filter(stmt, PayPearPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
PayPearPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
PayPearPayment.paypear_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, PayPearPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.PAYPEAR,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_rollypay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(RollyPayPayment).options(selectinload(RollyPayPayment.user)).order_by(desc(RollyPayPayment.created_at))
)
stmt = _apply_date_filter(stmt, RollyPayPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
RollyPayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
RollyPayPayment.rollypay_payment_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, RollyPayPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.ROLLYPAY,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_aurapay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(AuraPayPayment).options(selectinload(AuraPayPayment.user)).order_by(desc(AuraPayPayment.created_at))
stmt = _apply_date_filter(stmt, AuraPayPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
AuraPayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
AuraPayPayment.aurapay_invoice_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, AuraPayPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.AURAPAY,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_etoplatezhi(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(EtoplatezhiPayment)
.options(selectinload(EtoplatezhiPayment.user))
.order_by(desc(EtoplatezhiPayment.created_at))
)
stmt = _apply_date_filter(stmt, EtoplatezhiPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
EtoplatezhiPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
EtoplatezhiPayment.etoplatezhi_payment_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, EtoplatezhiPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.ETOPLATEZHI,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_antilopay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(AntilopayPayment)
.options(selectinload(AntilopayPayment.user))
.order_by(desc(AntilopayPayment.created_at))
)
stmt = _apply_date_filter(stmt, AntilopayPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
AntilopayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
AntilopayPayment.antilopay_payment_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, AntilopayPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.ANTILOPAY,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_jupiter(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(JupiterPayment).options(selectinload(JupiterPayment.user)).order_by(desc(JupiterPayment.created_at))
stmt = _apply_date_filter(stmt, JupiterPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
JupiterPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
JupiterPayment.jupiter_transaction_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, JupiterPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.JUPITER,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_donut(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(DonutPayment).options(selectinload(DonutPayment.user)).order_by(desc(DonutPayment.created_at))
stmt = _apply_date_filter(stmt, DonutPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
DonutPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
DonutPayment.donut_transaction_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, DonutPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.DONUT,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_lava(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(LavaPayment).options(selectinload(LavaPayment.user)).order_by(desc(LavaPayment.created_at))
stmt = _apply_date_filter(stmt, LavaPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
LavaPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
LavaPayment.lava_invoice_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, LavaPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.LAVA,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_stars(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(Transaction)
@@ -737,6 +1019,14 @@ _PROVIDER_SEARCH_MAP: dict[PaymentMethod, Any] = {
PaymentMethod.RIOPAY: _search_riopay,
PaymentMethod.SEVERPAY: _search_severpay,
PaymentMethod.OVERPAY: _search_overpay,
PaymentMethod.PAYPEAR: _search_paypear,
PaymentMethod.ROLLYPAY: _search_rollypay,
PaymentMethod.AURAPAY: _search_aurapay,
PaymentMethod.ETOPLATEZHI: _search_etoplatezhi,
PaymentMethod.ANTILOPAY: _search_antilopay,
PaymentMethod.JUPITER: _search_jupiter,
PaymentMethod.DONUT: _search_donut,
PaymentMethod.LAVA: _search_lava,
PaymentMethod.TELEGRAM_STARS: _search_stars,
}
+295
View File
@@ -30,10 +30,15 @@ from app.services.payment import (
WataPaymentMixin,
YooKassaPaymentMixin,
)
from app.services.payment.antilopay import AntilopayPaymentMixin
from app.services.payment.aurapay import AuraPayPaymentMixin
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
from app.services.payment.donut import DonutPaymentMixin
from app.services.payment.etoplatezhi import EtoplatezhiPaymentMixin
from app.services.payment.freekassa import FreekassaPaymentMixin
from app.services.payment.jupiter import JupiterPaymentMixin
from app.services.payment.kassa_ai import KassaAiPaymentMixin
from app.services.payment.lava import LavaPaymentMixin
from app.services.payment.overpay import OverpayPaymentMixin
from app.services.payment.paypear import PayPearPaymentMixin
from app.services.payment.riopay import RioPayPaymentMixin
@@ -482,6 +487,181 @@ async def link_aurapay_payment_to_transaction(*args, **kwargs):
return await aurapay_crud.link_aurapay_payment_to_transaction(*args, **kwargs)
async def create_etoplatezhi_payment(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.create_etoplatezhi_payment(*args, **kwargs)
async def get_etoplatezhi_payment_by_order_id(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.get_etoplatezhi_payment_by_order_id(*args, **kwargs)
async def get_etoplatezhi_payment_by_invoice_id(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.get_etoplatezhi_payment_by_invoice_id(*args, **kwargs)
async def get_etoplatezhi_payment_by_id(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.get_etoplatezhi_payment_by_id(*args, **kwargs)
async def get_etoplatezhi_payment_by_id_for_update(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.get_etoplatezhi_payment_by_id_for_update(*args, **kwargs)
async def update_etoplatezhi_payment_status(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.update_etoplatezhi_payment_status(*args, **kwargs)
async def link_etoplatezhi_payment_to_transaction(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.link_etoplatezhi_payment_to_transaction(*args, **kwargs)
async def create_antilopay_payment(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.create_antilopay_payment(*args, **kwargs)
async def get_antilopay_payment_by_order_id(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.get_antilopay_payment_by_order_id(*args, **kwargs)
async def get_antilopay_payment_by_invoice_id(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.get_antilopay_payment_by_invoice_id(*args, **kwargs)
async def get_antilopay_payment_by_id(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.get_antilopay_payment_by_id(*args, **kwargs)
async def get_antilopay_payment_by_id_for_update(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.get_antilopay_payment_by_id_for_update(*args, **kwargs)
async def update_antilopay_payment_status(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.update_antilopay_payment_status(*args, **kwargs)
async def link_antilopay_payment_to_transaction(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.link_antilopay_payment_to_transaction(*args, **kwargs)
async def create_jupiter_payment(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.create_jupiter_payment(*args, **kwargs)
async def get_jupiter_payment_by_order_id(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.get_jupiter_payment_by_order_id(*args, **kwargs)
async def get_jupiter_payment_by_invoice_id(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.get_jupiter_payment_by_invoice_id(*args, **kwargs)
async def get_jupiter_payment_by_id(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.get_jupiter_payment_by_id(*args, **kwargs)
async def get_jupiter_payment_by_id_for_update(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.get_jupiter_payment_by_id_for_update(*args, **kwargs)
async def update_jupiter_payment_status(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.update_jupiter_payment_status(*args, **kwargs)
async def link_jupiter_payment_to_transaction(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.link_jupiter_payment_to_transaction(*args, **kwargs)
async def create_donut_payment(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.create_donut_payment(*args, **kwargs)
async def get_donut_payment_by_order_id(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.get_donut_payment_by_order_id(*args, **kwargs)
async def get_donut_payment_by_invoice_id(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.get_donut_payment_by_invoice_id(*args, **kwargs)
async def get_donut_payment_by_id(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.get_donut_payment_by_id(*args, **kwargs)
async def get_donut_payment_by_id_for_update(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.get_donut_payment_by_id_for_update(*args, **kwargs)
async def update_donut_payment_status(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.update_donut_payment_status(*args, **kwargs)
async def link_donut_payment_to_transaction(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.link_donut_payment_to_transaction(*args, **kwargs)
async def create_lava_payment(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.create_lava_payment(*args, **kwargs)
async def get_lava_payment_by_order_id(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.get_lava_payment_by_order_id(*args, **kwargs)
async def get_lava_payment_by_invoice_id(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.get_lava_payment_by_invoice_id(*args, **kwargs)
async def get_lava_payment_by_id(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.get_lava_payment_by_id(*args, **kwargs)
async def get_lava_payment_by_id_for_update(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.get_lava_payment_by_id_for_update(*args, **kwargs)
async def update_lava_payment_status(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.update_lava_payment_status(*args, **kwargs)
async def link_lava_payment_to_transaction(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.link_lava_payment_to_transaction(*args, **kwargs)
# Mapping from model_name to getter function name for providers
# where it differs from the standard get_{model_name}_payment_by_id pattern.
_GETTER_OVERRIDES: dict[str, str] = {
@@ -509,6 +689,11 @@ class PaymentService(
RollyPayPaymentMixin,
OverpayPaymentMixin,
AuraPayPaymentMixin,
EtoplatezhiPaymentMixin,
AntilopayPaymentMixin,
JupiterPaymentMixin,
DonutPaymentMixin,
LavaPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
@@ -1016,6 +1201,116 @@ class PaymentService(
}
return None
# --- Etoplatezhi ------------------------------------------------------
if payment_method == 'etoplatezhi':
if not settings.is_etoplatezhi_enabled():
logger.warning('Etoplatezhi is not enabled, cannot create guest payment')
return None
result = await self.create_etoplatezhi_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
return_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'etoplatezhi')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'etoplatezhi',
}
return None
# --- Antilopay --------------------------------------------------------
if payment_method == 'antilopay':
if not settings.is_antilopay_enabled():
logger.warning('Antilopay is not enabled, cannot create guest payment')
return None
result = await self.create_antilopay_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
return_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'antilopay')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'antilopay',
}
return None
# --- Jupiter ----------------------------------------------------------
if payment_method == 'jupiter':
if not settings.is_jupiter_enabled():
logger.warning('Jupiter is not enabled, cannot create guest payment')
return None
result = await self.create_jupiter_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
return_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'jupiter')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'jupiter',
}
return None
# --- Donut ------------------------------------------------------------
if payment_method == 'donut':
if not settings.is_donut_enabled():
logger.warning('Donut is not enabled, cannot create guest payment')
return None
result = await self.create_donut_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
return_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'donut')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'donut',
}
return None
# --- Lava -------------------------------------------------------------
if payment_method == 'lava':
if not settings.is_lava_enabled():
logger.warning('Lava is not enabled, cannot create guest payment')
return None
result = await self.create_lava_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
return_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'lava')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'lava',
}
return None
# --- Telegram Stars ---------------------------------------------------
if payment_method == 'telegram_stars':
if not settings.TELEGRAM_STARS_ENABLED:
@@ -18,16 +18,24 @@ from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import (
AntilopayPayment,
AuraPayPayment,
CloudPaymentsPayment,
CryptoBotPayment,
DonutPayment,
EtoplatezhiPayment,
FreekassaPayment,
HeleketPayment,
JupiterPayment,
KassaAiPayment,
LavaPayment,
MulenPayPayment,
Pal24Payment,
PaymentMethod,
PayPearPayment,
PlategaPayment,
RioPayPayment,
RollyPayPayment,
SeverPayPayment,
Transaction,
TransactionType,
@@ -77,6 +85,11 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.RIOPAY,
PaymentMethod.SEVERPAY,
PaymentMethod.OVERPAY,
PaymentMethod.PAYPEAR,
PaymentMethod.ROLLYPAY,
PaymentMethod.AURAPAY,
# ETOPLATEZHI / ANTILOPAY / JUPITER / DONUT / LAVA — webhook-driven,
# без API-метода синхронизации БД, manual check не реализован.
}
)
@@ -98,6 +111,9 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.RIOPAY,
PaymentMethod.SEVERPAY,
PaymentMethod.OVERPAY,
PaymentMethod.PAYPEAR,
PaymentMethod.ROLLYPAY,
PaymentMethod.AURAPAY,
}
)
@@ -129,6 +145,22 @@ def method_display_name(method: PaymentMethod) -> str:
return settings.get_severpay_display_name()
if method == PaymentMethod.OVERPAY:
return settings.get_overpay_display_name()
if method == PaymentMethod.PAYPEAR:
return settings.get_paypear_display_name()
if method == PaymentMethod.ROLLYPAY:
return settings.get_rollypay_display_name()
if method == PaymentMethod.AURAPAY:
return settings.get_aurapay_display_name()
if method == PaymentMethod.ETOPLATEZHI:
return settings.get_etoplatezhi_display_name()
if method == PaymentMethod.ANTILOPAY:
return settings.get_antilopay_display_name()
if method == PaymentMethod.JUPITER:
return settings.get_jupiter_display_name()
if method == PaymentMethod.DONUT:
return settings.get_donut_display_name()
if method == PaymentMethod.LAVA:
return settings.get_lava_display_name()
if method == PaymentMethod.TELEGRAM_STARS:
return 'Telegram Stars'
return method.value
@@ -161,6 +193,22 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
return settings.is_severpay_enabled()
if method == PaymentMethod.OVERPAY:
return settings.is_overpay_enabled()
if method == PaymentMethod.PAYPEAR:
return settings.is_paypear_enabled()
if method == PaymentMethod.ROLLYPAY:
return settings.is_rollypay_enabled()
if method == PaymentMethod.AURAPAY:
return settings.is_aurapay_enabled()
if method == PaymentMethod.ETOPLATEZHI:
return settings.is_etoplatezhi_enabled()
if method == PaymentMethod.ANTILOPAY:
return settings.is_antilopay_enabled()
if method == PaymentMethod.JUPITER:
return settings.is_jupiter_enabled()
if method == PaymentMethod.DONUT:
return settings.is_donut_enabled()
if method == PaymentMethod.LAVA:
return settings.is_lava_enabled()
return False
@@ -410,6 +458,62 @@ def _is_riopay_pending(payment: RioPayPayment) -> bool:
return status in {'pending'}
def _is_paypear_pending(payment: PayPearPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or '').lower()
return status in {'pending', 'created', 'processing'}
def _is_rollypay_pending(payment: RollyPayPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or '').lower()
return status in {'pending', 'created', 'processing'}
def _is_aurapay_pending(payment: AuraPayPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or '').lower()
return status in {'pending', 'created', 'processing'}
def _is_etoplatezhi_pending(payment: EtoplatezhiPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or '').lower()
return status in {'pending', 'created', 'processing'}
def _is_antilopay_pending(payment: AntilopayPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or '').lower()
return status in {'pending', 'created', 'processing'}
def _is_jupiter_pending(payment: JupiterPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or '').lower()
return status in {'pending', 'created', 'processing'}
def _is_donut_pending(payment: DonutPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or '').lower()
return status in {'pending', 'created', 'processing'}
def _is_lava_pending(payment: LavaPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or '').lower()
return status in {'pending', 'created', 'processing'}
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
payload = payment.payload or ''
match = re.search(r'_(\d+)$', payload)
@@ -779,6 +883,214 @@ async def _fetch_severpay_payments(db: AsyncSession, cutoff: datetime) -> list[P
return records
async def _fetch_paypear_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(PayPearPayment)
.options(selectinload(PayPearPayment.user))
.where(PayPearPayment.created_at >= cutoff)
.order_by(desc(PayPearPayment.created_at))
)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
if not _is_paypear_pending(payment):
continue
record = _build_record(
PaymentMethod.PAYPEAR,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _fetch_rollypay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(RollyPayPayment)
.options(selectinload(RollyPayPayment.user))
.where(RollyPayPayment.created_at >= cutoff)
.order_by(desc(RollyPayPayment.created_at))
)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
if not _is_rollypay_pending(payment):
continue
record = _build_record(
PaymentMethod.ROLLYPAY,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _fetch_aurapay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(AuraPayPayment)
.options(selectinload(AuraPayPayment.user))
.where(AuraPayPayment.created_at >= cutoff)
.order_by(desc(AuraPayPayment.created_at))
)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
if not _is_aurapay_pending(payment):
continue
record = _build_record(
PaymentMethod.AURAPAY,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _fetch_etoplatezhi_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(EtoplatezhiPayment)
.options(selectinload(EtoplatezhiPayment.user))
.where(EtoplatezhiPayment.created_at >= cutoff)
.order_by(desc(EtoplatezhiPayment.created_at))
)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
if not _is_etoplatezhi_pending(payment):
continue
record = _build_record(
PaymentMethod.ETOPLATEZHI,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _fetch_antilopay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(AntilopayPayment)
.options(selectinload(AntilopayPayment.user))
.where(AntilopayPayment.created_at >= cutoff)
.order_by(desc(AntilopayPayment.created_at))
)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
if not _is_antilopay_pending(payment):
continue
record = _build_record(
PaymentMethod.ANTILOPAY,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _fetch_jupiter_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(JupiterPayment)
.options(selectinload(JupiterPayment.user))
.where(JupiterPayment.created_at >= cutoff)
.order_by(desc(JupiterPayment.created_at))
)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
if not _is_jupiter_pending(payment):
continue
record = _build_record(
PaymentMethod.JUPITER,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _fetch_donut_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(DonutPayment)
.options(selectinload(DonutPayment.user))
.where(DonutPayment.created_at >= cutoff)
.order_by(desc(DonutPayment.created_at))
)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
if not _is_donut_pending(payment):
continue
record = _build_record(
PaymentMethod.DONUT,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _fetch_lava_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(LavaPayment)
.options(selectinload(LavaPayment.user))
.where(LavaPayment.created_at >= cutoff)
.order_by(desc(LavaPayment.created_at))
)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
if not _is_lava_pending(payment):
continue
record = _build_record(
PaymentMethod.LAVA,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(Transaction)
@@ -828,6 +1140,14 @@ async def list_recent_pending_payments(
await _fetch_kassa_ai_payments(db, cutoff),
await _fetch_riopay_payments(db, cutoff),
await _fetch_severpay_payments(db, cutoff),
await _fetch_paypear_payments(db, cutoff),
await _fetch_rollypay_payments(db, cutoff),
await _fetch_aurapay_payments(db, cutoff),
await _fetch_etoplatezhi_payments(db, cutoff),
await _fetch_antilopay_payments(db, cutoff),
await _fetch_jupiter_payments(db, cutoff),
await _fetch_donut_payments(db, cutoff),
await _fetch_lava_payments(db, cutoff),
await _fetch_stars_transactions(db, cutoff),
)
@@ -1026,6 +1346,126 @@ async def get_payment_record(
expires_at=getattr(payment, 'expires_at', None),
)
if method == PaymentMethod.PAYPEAR:
payment = await db.get(PayPearPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=['user'])
return _build_record(
method,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if method == PaymentMethod.ROLLYPAY:
payment = await db.get(RollyPayPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=['user'])
return _build_record(
method,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if method == PaymentMethod.AURAPAY:
payment = await db.get(AuraPayPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=['user'])
return _build_record(
method,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if method == PaymentMethod.ETOPLATEZHI:
payment = await db.get(EtoplatezhiPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=['user'])
return _build_record(
method,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if method == PaymentMethod.ANTILOPAY:
payment = await db.get(AntilopayPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=['user'])
return _build_record(
method,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if method == PaymentMethod.JUPITER:
payment = await db.get(JupiterPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=['user'])
return _build_record(
method,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if method == PaymentMethod.DONUT:
payment = await db.get(DonutPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=['user'])
return _build_record(
method,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if method == PaymentMethod.LAVA:
payment = await db.get(LavaPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=['user'])
return _build_record(
method,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if method == PaymentMethod.TELEGRAM_STARS:
transaction = await db.get(Transaction, local_payment_id)
if not transaction:
@@ -1098,6 +1538,27 @@ async def run_manual_check(
payment = result.get('payment') if result else None
else:
payment = None
elif method == PaymentMethod.PAYPEAR:
paypear_payment = await db.get(PayPearPayment, local_payment_id)
if paypear_payment:
result = await payment_service.check_paypear_payment_status(db, paypear_payment.order_id)
payment = result.get('payment') if result else None
else:
payment = None
elif method == PaymentMethod.ROLLYPAY:
rollypay_payment = await db.get(RollyPayPayment, local_payment_id)
if rollypay_payment:
result = await payment_service.check_rollypay_payment_status(db, rollypay_payment.order_id)
payment = result.get('payment') if result else None
else:
payment = None
elif method == PaymentMethod.AURAPAY:
aurapay_payment = await db.get(AuraPayPayment, local_payment_id)
if aurapay_payment:
result = await payment_service.check_aurapay_payment_status(db, aurapay_payment.order_id)
payment = result.get('payment') if result else None
else:
payment = None
else:
logger.warning('Manual check requested for unsupported method', method=method)
return None
+47 -17
View File
@@ -208,27 +208,57 @@ class PayPearService:
logger.exception('PayPear API connection error', error=e)
raise
def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool:
"""Верификация подписи webhook PayPear через HMAC-SHA256.
# PayPear documented webhook source IPs
WEBHOOK_ALLOWED_IPS: set[str] = {'158.160.85.101'}
PayPear sends signature in the webhook JSON field 'signature'.
The signature is HMAC-SHA256(secret_key, raw_body).
def verify_webhook_signature(self, raw_body: bytes, received_signature: str, client_ip: str | None = None) -> bool:
"""Верификация webhook PayPear.
PayPear documentation does not specify the exact signature algorithm.
We try HMAC-SHA256(secret_key, body_without_signature_field) the most common pattern.
If signature verification fails, fall back to IP allowlist check (recommended by PayPear docs).
"""
try:
if not received_signature:
logger.warning('PayPear webhook: отсутствует signature')
return False
import json as json_mod
expected = hmac.new(
self.secret_key.encode('utf-8'),
raw_body,
hashlib.sha256,
).hexdigest()
# Try signature verification (body without 'signature' field, sorted keys, compact separators)
if received_signature and self.secret_key:
try:
payload = json_mod.loads(raw_body)
payload_without_sig = {k: v for k, v in payload.items() if k != 'signature'}
body_to_sign = json_mod.dumps(payload_without_sig, separators=(',', ':'), sort_keys=True).encode(
'utf-8'
)
return hmac.compare_digest(expected, received_signature)
except Exception as e:
logger.error('PayPear webhook verify error', error=e)
return False
expected = hmac.new(
self.secret_key.encode('utf-8'),
body_to_sign,
hashlib.sha256,
).hexdigest()
if hmac.compare_digest(expected, received_signature):
return True
# Try without sort_keys (original key order)
body_to_sign_unsorted = json_mod.dumps(payload_without_sig, separators=(',', ':')).encode('utf-8')
expected_unsorted = hmac.new(
self.secret_key.encode('utf-8'),
body_to_sign_unsorted,
hashlib.sha256,
).hexdigest()
if hmac.compare_digest(expected_unsorted, received_signature):
return True
logger.debug('PayPear signature mismatch, falling back to IP check')
except Exception as e:
logger.debug('PayPear signature verify error, falling back to IP check', error=e)
# Fallback: IP allowlist (recommended by PayPear docs)
if client_ip and client_ip in self.WEBHOOK_ALLOWED_IPS:
return True
logger.warning('PayPear webhook: signature mismatch and IP not in allowlist', client_ip=client_ip)
return False
# Singleton instance
+3
View File
@@ -77,6 +77,9 @@ PERMISSION_REGISTRY: dict[str, list[str]] = {
'pinned_messages': ['read', 'create', 'edit', 'delete'],
'landings': ['read', 'create', 'edit', 'delete'],
'updates': ['read', 'manage'],
'bulk_actions': ['read', 'execute'],
'info_pages': ['read', 'create', 'edit', 'delete'],
'news': ['read', 'create', 'edit', 'delete'],
}
+9 -2
View File
@@ -205,11 +205,13 @@ async def unpin_active_pinned_message(
"""
Открепляет активное сообщение у всех пользователей.
ВАЖНО: Извлекаем telegram_id в список ДО начала долгой операции,
ВАЖНО: Деактивация в БД происходит ПОСЛЕ откреплений в Telegram,
чтобы при сбое можно было повторить операцию.
Извлекаем telegram_id в список ДО начала долгой операции,
чтобы избежать обращения к ORM-объектам после истечения таймаута
соединения с БД.
"""
pinned_message = await deactivate_active_pinned_message(db)
pinned_message = await get_active_pinned_message(db)
if not pinned_message:
return 0, 0, False
@@ -260,6 +262,11 @@ async def unpin_active_pinned_message(
await asyncio.gather(*tasks)
await asyncio.sleep(0.05)
# Деактивируем ПОСЛЕ откреплений через fresh session —
# исходная сессия может протухнуть за время долгого цикла Telegram API
async with AsyncSessionLocal() as fresh_db:
await deactivate_active_pinned_message(fresh_db)
return unpinned_count, failed_count, True
+5 -2
View File
@@ -603,6 +603,9 @@ class PricingEngine:
period_pct = 0
devices_pct = 0
promo_group = self.resolve_promo_group(user)
# Only apply promo group discount if the tariff is available for this group
if promo_group is not None and not tariff.is_available_for_promo_group(promo_group.id):
promo_group = None
if promo_group is not None:
period_pct = promo_group.get_discount_percent('period', period_days)
devices_pct = promo_group.get_discount_percent('devices', period_days)
@@ -612,9 +615,9 @@ class PricingEngine:
discounted_base = self.apply_discount(base_price, period_pct)
discounted_devices = self.apply_discount(devices_price, devices_pct)
# Traffic uses addon discount (checks apply_discounts_to_addons flag)
# Traffic uses addon discount — but only if promo_group passed the tariff availability check
discounted_traffic = traffic_price
if traffic_price > 0 and user:
if traffic_price > 0 and user and promo_group is not None:
discounted_traffic, _, _ = self.calculate_traffic_discount(traffic_price, user)
base_group_disc = base_price - discounted_base
+4
View File
@@ -301,6 +301,10 @@ class ReferralContestService:
lines.append('')
lines.append(f'Приз: {html.escape(contest.prize_text)}')
# Respect per-category enable/disable
if not getattr(settings, 'ADMIN_NOTIFICATIONS_PROMO_ENABLED', True):
return
try:
await self.bot.send_message(
chat_id=chat_id,
+67
View File
@@ -99,6 +99,73 @@ async def clear_pending_referral(telegram_id: int) -> None:
pass
# ---------------------------------------------------------------------------
# Pending campaign helpers (Redis)
#
# Mirrors pending_referral: lets us survive the case where /start <campaign>
# stored campaign_id only in FSM, but the user opened the cabinet WebApp before
# completing bot registration. The cabinet auth route reads this as a fallback
# when the HTTP request didn't carry an explicit campaign_slug.
# ---------------------------------------------------------------------------
_PENDING_CAMPAIGN_TTL = 7 * 24 * 3600 # 7 days
async def save_pending_campaign(telegram_id: int, campaign_slug: str, campaign_id: int) -> bool:
"""Save pending campaign attribution to Redis for a not-yet-registered user.
Called from /start handler immediately after resolving an advertising campaign.
Picked up by the cabinet auth route if the user opens the WebApp before
completing the bot registration flow.
"""
client = _get_redis()
if client is None:
return False
try:
key = f'pending_campaign:{telegram_id}'
data = json.dumps({'campaign_slug': campaign_slug, 'campaign_id': campaign_id})
await client.setex(key, _PENDING_CAMPAIGN_TTL, data)
logger.info(
'Saved pending campaign to Redis',
telegram_id=telegram_id,
campaign_slug=campaign_slug,
campaign_id=campaign_id,
)
return True
except Exception as exc:
logger.warning('Failed to save pending campaign to Redis', error=exc)
return False
async def get_pending_campaign(telegram_id: int) -> dict[str, str | int] | None:
"""Get pending campaign from Redis.
Returns ``{'campaign_slug': ..., 'campaign_id': ...}`` or ``None``.
"""
client = _get_redis()
if client is None:
return None
try:
key = f'pending_campaign:{telegram_id}'
data = await client.get(key)
if data:
return json.loads(data)
return None
except Exception as exc:
logger.warning('Failed to get pending campaign from Redis', error=exc)
return None
async def clear_pending_campaign(telegram_id: int) -> None:
"""Clear pending campaign after successful application."""
client = _get_redis()
if client is None:
return
try:
await client.delete(f'pending_campaign:{telegram_id}')
except Exception:
pass
async def _is_commission_limit_reached(db: AsyncSession, referrer_id: int, referral_id: int) -> bool:
"""Проверяет, исчерпан ли лимит комиссионных платежей для пары реферер-реферал."""
if settings.REFERRAL_MAX_COMMISSION_PAYMENTS <= 0:
+3 -1
View File
@@ -2397,7 +2397,9 @@ class RemnaWaveService:
user.remnawave_uuid = panel_uuid
return ('updated', sub, None)
except RemnaWaveAPIError as api_error:
if api_error.status_code == 404:
# A018 = "user not found" in some RemnaWave versions (may return 400 or 404)
error_code = (api_error.response_data or {}).get('errorCode', '')
if api_error.status_code == 404 or error_code == 'A018':
new_user = await api.create_user(**create_kwargs)
return ('created', sub, new_user)
raise
+12 -2
View File
@@ -994,6 +994,16 @@ class RemnaWaveWebhookService:
async def _handle_user_deleted(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
# Suppress webhook if this deletion was initiated by delete_user_account —
# prevents deadlock between the ongoing deletion transaction and this handler
if self._is_intentional_panel_deletion_event(data):
logger.info(
'Webhook user.deleted suppressed — intentional panel deletion in progress',
user_id=user.id,
uuid=data.get('uuid'),
)
return
user_id = user.id
sub_id = subscription.id if subscription else None
@@ -1075,8 +1085,8 @@ class RemnaWaveWebhookService:
subscription.connected_squads = []
subscription.updated_at = datetime.now(UTC)
if settings.is_multi_tariff_enabled():
subscription.remnawave_uuid = None
# Always clear stale UUID — panel user was deleted
subscription.remnawave_uuid = None
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id))
@@ -3,6 +3,7 @@
from __future__ import annotations
import html
import math
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
@@ -1533,7 +1534,7 @@ async def _auto_add_devices(
# Recompute price fresh under lock (pricing config may have changed since cart was saved)
devices_price_per_month = devices_to_add * tariff_device_price
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(
user,
'devices',
@@ -2137,7 +2138,7 @@ async def try_auto_extend_expired_after_topup(
from app.database.crud.subscription import get_all_subscriptions_by_user_id
all_subs = await get_all_subscriptions_by_user_id(db, user.id)
expired_subs = [s for s in all_subs if s.status == SubscriptionStatus.EXPIRED.value and not s.is_trial]
expired_subs = [s for s in all_subs if s.status == SubscriptionStatus.EXPIRED.value and s.is_trial is False]
if not expired_subs:
subscription = None
else:
@@ -2153,9 +2154,10 @@ async def try_auto_extend_expired_after_topup(
return False
# Only process expired subscriptions (not trial, not disabled)
# NULL-safe: is_trial can be None in legacy rows — treat as trial
if subscription.status != SubscriptionStatus.EXPIRED.value:
return False
if subscription.is_trial:
if subscription.is_trial is not False:
return False
# Only process subscriptions expired within the last 30 days
+17 -19
View File
@@ -70,8 +70,8 @@ class ReadOnlySettingError(RuntimeError):
class BotConfigurationService:
EXCLUDED_KEYS: set[str] = {'BOT_TOKEN', 'ADMIN_IDS'}
READ_ONLY_KEYS: set[str] = {'EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'}
PLAIN_TEXT_KEYS: set[str] = {'EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'}
READ_ONLY_KEYS: set[str] = set()
PLAIN_TEXT_KEYS: set[str] = set()
CATEGORY_TITLES: dict[str, str] = {
'CORE': '🤖 Основные настройки',
@@ -95,13 +95,17 @@ class BotConfigurationService:
'ROLLYPAY': '💳 RollyPay',
'OVERPAY': '💳 Overpay',
'AURAPAY': '💳 AuraPay',
'ANTILOPAY': '🦌 Antilopay',
'ETOPLATEZHI': '💳 Etoplatezhi',
'JUPITER': '🪐 Jupiter',
'DONUT': '🍩 Donut',
'LAVA': '🌋 Lava',
'YOOKASSA': '🟣 YooKassa',
'PLATEGA': '💳 {platega_name}',
'TRIBUTE': '🎁 Tribute',
'MULENPAY': '💰 {mulenpay_name}',
'PAL24': '🏦 PAL24 / PayPalych',
'WATA': '💠 Wata',
'EXTERNAL_ADMIN': '🛡️ Внешняя админка',
'SUBSCRIPTIONS_CORE': '📅 Подписки и лимиты',
'SIMPLE_SUBSCRIPTION': '⚡ Простая покупка',
'PERIODS': '📆 Периоды подписок',
@@ -160,6 +164,11 @@ class BotConfigurationService:
'ROLLYPAY': 'RollyPay: платёжный шлюз rollypay.io с СБП, картами и криптовалютой.',
'OVERPAY': 'Overpay: платёжный шлюз pay.overpay.io с mTLS и поддержкой карт и СБП.',
'AURAPAY': 'AuraPay: платёжный шлюз aurapay.tech с поддержкой карт и СБП.',
'ANTILOPAY': 'Antilopay: lk.antilopay.com, оплата картой, СБП и SberPay.',
'ETOPLATEZHI': 'Etoplatezhi: paymentpage.etoplatezhi.ru, оплата картой и через СБП.',
'JUPITER': 'Jupiter (FPGate P2P v2.1): app.juppiter.tech, эквайринг СБП с HMAC-SHA256.',
'DONUT': 'Donut P2P: gw.donut.business, P2P-оплата картой, СБП по телефону и QR.',
'LAVA': 'Lava Business: gate.lava.ru, оплата картой и СБП с HMAC-SHA256 и подтверждением через webhook.',
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
'PAL24': 'PAL24 / PayPalych подключения и лимиты.',
@@ -168,7 +177,6 @@ class BotConfigurationService:
'TELEGRAM_WIDGET': 'Внешний вид виджета авторизации Telegram на странице входа в кабинет.',
'TELEGRAM_OIDC': 'OpenID Connect авторизация через Telegram (новая система). Требует настройки в BotFather > Bot Settings > Web Login.',
'WATA': 'Wata: токен доступа, тип платежа и пределы сумм.',
'EXTERNAL_ADMIN': 'Токен внешней админки для проверки запросов.',
'SUBSCRIPTIONS_CORE': 'Лимиты устройств, трафика и базовые цены подписок.',
'SIMPLE_SUBSCRIPTION': 'Параметры упрощённой покупки: период, трафик, устройства и сквады.',
'PERIODS': 'Доступные периоды подписок и продлений.',
@@ -375,13 +383,17 @@ class BotConfigurationService:
'ROLLYPAY_': 'ROLLYPAY',
'OVERPAY_': 'OVERPAY',
'AURAPAY_': 'AURAPAY',
'ANTILOPAY_': 'ANTILOPAY',
'ETOPLATEZHI_': 'ETOPLATEZHI',
'JUPITER_': 'JUPITER',
'DONUT_': 'DONUT',
'LAVA_': 'LAVA',
'PLATEGA_': 'PLATEGA',
'MULENPAY_': 'MULENPAY',
'PAL24_': 'PAL24',
'PAYMENT_': 'PAYMENT',
'PAYMENT_VERIFICATION_': 'PAYMENT_VERIFICATION',
'WATA_': 'WATA',
'EXTERNAL_ADMIN_': 'EXTERNAL_ADMIN',
'SIMPLE_SUBSCRIPTION_': 'SIMPLE_SUBSCRIPTION',
'CONNECT_BUTTON_HAPP': 'HAPP',
'HAPP_': 'HAPP',
@@ -738,20 +750,6 @@ class BotConfigurationService:
'Если результат пустой, используется user_{telegram_id}.'
),
},
'EXTERNAL_ADMIN_TOKEN': {
'description': 'Приватный токен, который использует внешняя админка для проверки запросов.',
'format': 'Значение генерируется автоматически из username бота и его токена и доступно только для чтения.',
'example': 'Генерируется автоматически',
'warning': 'Токен обновится при смене username или токена бота.',
'dependencies': 'Username телеграм-бота, токен бота',
},
'EXTERNAL_ADMIN_TOKEN_BOT_ID': {
'description': 'Идентификатор телеграм-бота, с которым связан токен внешней админки.',
'format': 'Проставляется автоматически после первого запуска и не редактируется вручную.',
'example': '123456789',
'warning': 'Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.',
'dependencies': 'Результат вызова getMe() в Telegram Bot API',
},
'TRIAL_USER_TAG': {
'description': (
'Тег, который бот передаст пользователю при активации триальной подписки в панели RemnaWave.'
+240
View File
@@ -184,6 +184,187 @@ def get_available_payment_methods() -> list[dict[str, str]]:
}
)
if settings.is_severpay_enabled():
severpay_name = settings.get_severpay_display_name()
methods.append(
{
'id': 'severpay',
'name': f'Банковская карта ({severpay_name})',
'icon': '💳',
'description': f'через {severpay_name}',
'callback': 'topup_severpay',
}
)
if settings.is_paypear_enabled():
paypear_name = settings.get_paypear_display_name()
methods.append(
{
'id': 'paypear',
'name': paypear_name,
'icon': '💳',
'description': f'через {paypear_name}',
'callback': 'topup_paypear',
}
)
if settings.is_rollypay_enabled():
rollypay_name = settings.get_rollypay_display_name()
methods.append(
{
'id': 'rollypay',
'name': rollypay_name,
'icon': '💳',
'description': f'через {rollypay_name}',
'callback': 'topup_rollypay',
}
)
if settings.is_overpay_enabled():
overpay_name = settings.get_overpay_display_name()
methods.append(
{
'id': 'overpay',
'name': overpay_name,
'icon': '💳',
'description': f'через {overpay_name}',
'callback': 'topup_overpay',
}
)
if settings.is_aurapay_sbp_enabled():
sbp_name = settings.get_aurapay_sbp_display_name()
methods.append(
{
'id': 'aurapay_sbp',
'name': sbp_name,
'icon': '📱',
'description': f'через {sbp_name}',
'callback': 'topup_aurapay_sbp',
}
)
if settings.is_aurapay_card_enabled():
card_name = settings.get_aurapay_card_display_name()
methods.append(
{
'id': 'aurapay_card',
'name': card_name,
'icon': '💳',
'description': f'через {card_name}',
'callback': 'topup_aurapay_card',
}
)
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()
methods.append(
{
'id': 'aurapay',
'name': aurapay_name,
'icon': '💳',
'description': f'через {aurapay_name}',
'callback': 'topup_aurapay',
}
)
if settings.is_etoplatezhi_sbp_enabled():
sbp_name = settings.get_etoplatezhi_sbp_display_name()
methods.append(
{
'id': 'etoplatezhi_sbp',
'name': sbp_name,
'icon': '📱',
'description': f'через {sbp_name}',
'callback': 'topup_etoplatezhi_sbp',
}
)
if settings.is_etoplatezhi_card_enabled():
card_name = settings.get_etoplatezhi_card_display_name()
methods.append(
{
'id': 'etoplatezhi_card',
'name': card_name,
'icon': '💳',
'description': f'через {card_name}',
'callback': 'topup_etoplatezhi_card',
}
)
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()
methods.append(
{
'id': 'etoplatezhi',
'name': etoplatezhi_name,
'icon': '💳',
'description': f'через {etoplatezhi_name}',
'callback': 'topup_etoplatezhi',
}
)
if settings.is_antilopay_sbp_enabled():
sbp_name = settings.get_antilopay_sbp_display_name()
methods.append(
{
'id': 'antilopay_sbp',
'name': sbp_name,
'icon': '📱',
'description': f'через {sbp_name}',
'callback': 'topup_antilopay_sbp',
}
)
if settings.is_antilopay_card_enabled():
card_name = settings.get_antilopay_card_display_name()
methods.append(
{
'id': 'antilopay_card',
'name': card_name,
'icon': '💳',
'description': f'через {card_name}',
'callback': 'topup_antilopay_card',
}
)
if settings.is_antilopay_sberpay_enabled():
sberpay_name = settings.get_antilopay_sberpay_display_name()
methods.append(
{
'id': 'antilopay_sberpay',
'name': sberpay_name,
'icon': '💳',
'description': f'через {sberpay_name}',
'callback': 'topup_antilopay_sberpay',
}
)
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()
methods.append(
{
'id': 'antilopay',
'name': antilopay_name,
'icon': '💳',
'description': f'через {antilopay_name}',
'callback': 'topup_antilopay',
}
)
if settings.is_support_topup_enabled():
methods.append(
{
@@ -311,6 +492,34 @@ def is_payment_method_available(method_id: str) -> bool:
return settings.is_kassa_ai_enabled()
if method_id == 'riopay':
return settings.is_riopay_enabled()
if method_id == 'severpay':
return settings.is_severpay_enabled()
if method_id == 'paypear':
return settings.is_paypear_enabled()
if method_id == 'rollypay':
return settings.is_rollypay_enabled()
if method_id == 'overpay':
return settings.is_overpay_enabled()
if method_id == 'aurapay':
return settings.is_aurapay_enabled()
if method_id == 'aurapay_sbp':
return settings.is_aurapay_sbp_enabled()
if method_id == 'aurapay_card':
return settings.is_aurapay_card_enabled()
if method_id == 'etoplatezhi':
return settings.is_etoplatezhi_enabled()
if method_id == 'etoplatezhi_sbp':
return settings.is_etoplatezhi_sbp_enabled()
if method_id == 'etoplatezhi_card':
return settings.is_etoplatezhi_card_enabled()
if method_id == 'antilopay':
return settings.is_antilopay_enabled()
if method_id == 'antilopay_sbp':
return settings.is_antilopay_sbp_enabled()
if method_id == 'antilopay_card':
return settings.is_antilopay_card_enabled()
if method_id == 'antilopay_sberpay':
return settings.is_antilopay_sberpay_enabled()
if method_id == 'support':
return settings.is_support_topup_enabled()
return False
@@ -333,6 +542,21 @@ def get_payment_method_status() -> dict[str, bool]:
'cloudpayments': settings.is_cloudpayments_enabled(),
'freekassa': settings.is_freekassa_enabled(),
'kassa_ai': settings.is_kassa_ai_enabled(),
'riopay': settings.is_riopay_enabled(),
'severpay': settings.is_severpay_enabled(),
'paypear': settings.is_paypear_enabled(),
'rollypay': settings.is_rollypay_enabled(),
'overpay': settings.is_overpay_enabled(),
'aurapay': settings.is_aurapay_enabled(),
'aurapay_sbp': settings.is_aurapay_sbp_enabled(),
'aurapay_card': settings.is_aurapay_card_enabled(),
'etoplatezhi': settings.is_etoplatezhi_enabled(),
'etoplatezhi_sbp': settings.is_etoplatezhi_sbp_enabled(),
'etoplatezhi_card': settings.is_etoplatezhi_card_enabled(),
'antilopay': settings.is_antilopay_enabled(),
'antilopay_sbp': settings.is_antilopay_sbp_enabled(),
'antilopay_card': settings.is_antilopay_card_enabled(),
'antilopay_sberpay': settings.is_antilopay_sberpay_enabled(),
'support': settings.is_support_topup_enabled(),
}
@@ -366,4 +590,20 @@ def get_enabled_payment_methods_count() -> int:
count += 1
if settings.is_kassa_ai_enabled():
count += 1
if settings.is_riopay_enabled():
count += 1
if settings.is_severpay_enabled():
count += 1
if settings.is_paypear_enabled():
count += 1
if settings.is_rollypay_enabled():
count += 1
if settings.is_overpay_enabled():
count += 1
if settings.is_aurapay_enabled():
count += 1
if settings.is_etoplatezhi_enabled():
count += 1
if settings.is_antilopay_enabled():
count += 1
return count

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